refactor(types): PEP-604 union sweep + UP007/UP045 enforcement

ruff --select UP007,UP045 --fix converted ~1760 sites across the
backend: `Optional[T]` → `T | None`, `Union[X, Y]` → `X | Y`. The
remaining module-level alias targets that ruff conservatively skips
(BindableFloatInput, ColorList, DeviceConfig) were converted by hand
earlier in the pass. black -formatted the result so the wider unions
fit cleanly under the 100-char line budget.

pyproject.toml now sets [tool.ruff.lint] extend-select = ["UP007",
"UP045"] so future legacy imports fire CI on every push. The
pre-commit ruff hook was bumped from v0.8.0 -> v0.15.12 to recognise
UP045 (split off from UP007 in v0.13).
This commit is contained in:
2026-05-23 01:21:44 +03:00
parent ea7ee88490
commit 888f8fd16e
240 changed files with 2102 additions and 2215 deletions
+4 -1
View File
@@ -6,7 +6,10 @@ repos:
args: [--line-length=100, --target-version=py311] args: [--line-length=100, --target-version=py311]
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0 # Bumped from v0.8.0 so the hook recognises UP045
# (non-pep604-annotation-optional), which the v0.13+ ruff split off
# from UP007. Pyproject.toml extend-selects both rules.
rev: v0.15.12
hooks: hooks:
- id: ruff - id: ruff
args: [--line-length=100, --target-version=py311] args: [--line-length=100, --target-version=py311]
+8
View File
@@ -117,3 +117,11 @@ target-version = ['py311']
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py311" target-version = "py311"
[tool.ruff.lint]
# E + F are ruff's defaults; UP007 + UP045 enforce PEP-604 `X | Y` and
# `T | None` style so we don't drift back to the legacy `Union[X, Y]` /
# `Optional[T]` imports the REVIEW_TODO mechanical sweep removed.
# Recent ruff versions split the rule — UP007 covers `Union`, UP045
# covers `Optional`.
extend-select = ["UP007", "UP045"]
+4 -4
View File
@@ -8,11 +8,11 @@ inside an Android application. Sets up Android-specific paths
import asyncio import asyncio
import os import os
import threading import threading
from typing import Any, Optional from typing import Any
_server_thread: Optional[threading.Thread] = None _server_thread: threading.Thread | None = None
_server: Optional[Any] = None # uvicorn.Server _server: Any | None = None # uvicorn.Server
_loop: Optional[asyncio.AbstractEventLoop] = None _loop: asyncio.AbstractEventLoop | None = None
def start_server(data_dir: str, port: int = 8080) -> None: def start_server(data_dir: str, port: int = 8080) -> None:
@@ -3,7 +3,7 @@
import asyncio import asyncio
import threading import threading
import time import time
from typing import Callable, Optional from typing import Callable
import numpy as np import numpy as np
from starlette.websockets import WebSocket from starlette.websockets import WebSocket
@@ -61,8 +61,8 @@ async def stream_capture_test(
websocket: WebSocket, websocket: WebSocket,
engine_factory: Callable, engine_factory: Callable,
duration: float, duration: float,
pp_filters: Optional[list] = None, pp_filters: list | None = None,
preview_width: Optional[int] = None, preview_width: int | None = None,
) -> None: ) -> None:
"""Run a capture test, streaming intermediate thumbnails and a final full-res frame. """Run a capture test, streaming intermediate thumbnails and a final full-res frame.
@@ -1,7 +1,7 @@
"""Audio source routes: CRUD for audio sources + real-time test WebSocket.""" """Audio source routes: CRUD for audio sources + real-time test WebSocket."""
import asyncio import asyncio
from typing import Annotated, Optional from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query
from starlette.websockets import WebSocket, WebSocketDisconnect from starlette.websockets import WebSocket, WebSocketDisconnect
@@ -91,7 +91,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
@router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"]) @router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"])
async def list_audio_sources( async def list_audio_sources(
_auth: AuthRequired, _auth: AuthRequired,
source_type: Optional[str] = Query( source_type: str | None = Query(
None, description="Filter by source_type: capture or processed" None, description="Filter by source_type: capture or processed"
), ),
store: AudioSourceStore = Depends(get_audio_source_store), store: AudioSourceStore = Depends(get_audio_source_store),
@@ -1,7 +1,7 @@
"""Output target routes: CRUD endpoints and batch state/metrics queries.""" """Output target routes: CRUD endpoints and batch state/metrics queries."""
import asyncio import asyncio
from typing import Annotated, Optional from typing import Annotated
from fastapi import APIRouter, Body, HTTPException, Depends from fastapi import APIRouter, Body, HTTPException, Depends
@@ -421,7 +421,7 @@ async def get_target(
def _resolve_effective_color_vs_id( def _resolve_effective_color_vs_id(
target_store: OutputTargetStore, target_id: str, payload_id: Optional[str] target_store: OutputTargetStore, target_id: str, payload_id: str | None
) -> str: ) -> str:
if payload_id is not None: if payload_id is not None:
return payload_id return payload_id
+1 -2
View File
@@ -9,7 +9,6 @@ import subprocess
import sys import sys
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional
import os import os
@@ -190,7 +189,7 @@ async def list_all_tags(_: AuthRequired):
@router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"]) @router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"])
async def get_displays( async def get_displays(
_: AuthRequired, _: AuthRequired,
engine_type: Optional[str] = Query(None, description="Engine type to get displays for"), engine_type: str | None = Query(None, description="Engine type to get displays for"),
): ):
"""Get list of available displays. """Get list of available displays.
@@ -1,7 +1,7 @@
"""Value source routes: CRUD for value sources.""" """Value source routes: CRUD for value sources."""
import asyncio import asyncio
from typing import Annotated, Optional from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
@@ -289,7 +289,7 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
@router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"]) @router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"])
async def list_value_sources( async def list_value_sources(
_auth: AuthRequired, _auth: AuthRequired,
source_type: Optional[str] = Query( source_type: str | None = Query(
None, None,
description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene", description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene",
), ),
+9 -9
View File
@@ -1,7 +1,7 @@
"""Asset schemas (CRUD).""" """Asset schemas (CRUD)."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -9,15 +9,15 @@ from pydantic import BaseModel, Field
class AssetUpdate(BaseModel): class AssetUpdate(BaseModel):
"""Request to update asset metadata.""" """Request to update asset metadata."""
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Display name") name: str | None = Field(None, min_length=1, max_length=100, description="Display name")
description: Optional[str] = Field(None, max_length=500, description="Optional description") description: str | None = Field(None, max_length=500, description="Optional description")
tags: Optional[List[str]] = Field(None, description="User-defined tags") tags: List[str] | None = Field(None, description="User-defined tags")
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -33,15 +33,15 @@ class AssetResponse(BaseModel):
mime_type: str = Field(description="MIME type") mime_type: str = Field(description="MIME type")
asset_type: str = Field(description="Asset type: sound, image, video, other") asset_type: str = Field(description="Asset type: sound, image, video, other")
size_bytes: int = Field(description="File size in bytes") size_bytes: int = Field(description="File size in bytes")
description: Optional[str] = Field(None, description="Description") description: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,7 +1,7 @@
"""Audio processing template schemas.""" """Audio processing template schemas."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class AudioProcessingTemplateCreate(BaseModel):
filters: List[FilterInstanceSchema] = Field( filters: List[FilterInstanceSchema] = Field(
default_factory=list, description="Ordered list of audio filter instances" default_factory=list, description="Ordered list of audio filter instances"
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class AudioProcessingTemplateCreate(BaseModel):
class AudioProcessingTemplateUpdate(BaseModel): class AudioProcessingTemplateUpdate(BaseModel):
"""Request to update an audio processing template.""" """Request to update an audio processing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field( filters: List[FilterInstanceSchema] | None = Field(
None, description="Ordered list of audio filter instances" None, description="Ordered list of audio filter instances"
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -61,13 +61,13 @@ class AudioProcessingTemplateResponse(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")
description: Optional[str] = Field(None, description="Template description") description: str | None = Field(None, description="Template description")
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
+25 -31
View File
@@ -1,7 +1,7 @@
"""Audio source schemas — discriminated unions per source type.""" """Audio source schemas — discriminated unions per source type."""
from datetime import datetime from datetime import datetime
from typing import Annotated, List, Literal, Optional, Union from typing import Annotated, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag from pydantic import BaseModel, Discriminator, Field, Tag
@@ -15,16 +15,16 @@ class _AudioSourceResponseBase(BaseModel):
id: str = Field(description="Source ID") id: str = Field(description="Source ID")
name: str = Field(description="Source name") name: str = Field(description="Source name")
description: Optional[str] = Field(None, description="Description") description: str | None = 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")
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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -35,7 +35,7 @@ class CaptureAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["capture"] = "capture" source_type: Literal["capture"] = "capture"
device_index: int = Field(description="Audio device index (-1 = default)") device_index: int = Field(description="Audio device index (-1 = default)")
is_loopback: bool = Field(description="WASAPI loopback mode") is_loopback: bool = Field(description="WASAPI loopback mode")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID") audio_template_id: str | None = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceResponse(_AudioSourceResponseBase): class ProcessedAudioSourceResponse(_AudioSourceResponseBase):
@@ -45,10 +45,8 @@ class ProcessedAudioSourceResponse(_AudioSourceResponseBase):
AudioSourceResponse = Annotated[ AudioSourceResponse = Annotated[
Union[ Annotated[CaptureAudioSourceResponse, Tag("capture")]
Annotated[CaptureAudioSourceResponse, Tag("capture")], | Annotated[ProcessedAudioSourceResponse, Tag("processed")],
Annotated[ProcessedAudioSourceResponse, Tag("processed")],
],
Discriminator("source_type"), Discriminator("source_type"),
] ]
@@ -61,14 +59,14 @@ class _AudioSourceCreateBase(BaseModel):
"""Shared fields for all audio source create requests.""" """Shared fields for all audio source create requests."""
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: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -79,7 +77,7 @@ class CaptureAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["capture"] = "capture" source_type: Literal["capture"] = "capture"
device_index: int = Field(-1, description="Audio device index (-1 = default)") device_index: int = Field(-1, description="Audio device index (-1 = default)")
is_loopback: bool = Field(True, description="True for system audio (WASAPI loopback)") is_loopback: bool = Field(True, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID") audio_template_id: str | None = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceCreate(_AudioSourceCreateBase): class ProcessedAudioSourceCreate(_AudioSourceCreateBase):
@@ -89,10 +87,8 @@ class ProcessedAudioSourceCreate(_AudioSourceCreateBase):
AudioSourceCreate = Annotated[ AudioSourceCreate = Annotated[
Union[ Annotated[CaptureAudioSourceCreate, Tag("capture")]
Annotated[CaptureAudioSourceCreate, Tag("capture")], | Annotated[ProcessedAudioSourceCreate, Tag("processed")],
Annotated[ProcessedAudioSourceCreate, Tag("processed")],
],
Discriminator("source_type"), Discriminator("source_type"),
] ]
@@ -104,15 +100,15 @@ AudioSourceCreate = Annotated[
class _AudioSourceUpdateBase(BaseModel): class _AudioSourceUpdateBase(BaseModel):
"""Shared fields for all audio source update requests.""" """Shared fields for all audio source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -121,24 +117,22 @@ class _AudioSourceUpdateBase(BaseModel):
class CaptureAudioSourceUpdate(_AudioSourceUpdateBase): class CaptureAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["capture"] = "capture" source_type: Literal["capture"] = "capture"
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)") device_index: int | None = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)") is_loopback: bool | None = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID") audio_template_id: str | None = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceUpdate(_AudioSourceUpdateBase): class ProcessedAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["processed"] = "processed" source_type: Literal["processed"] = "processed"
audio_source_id: Optional[str] = Field(None, description="Input audio source ID") audio_source_id: str | None = Field(None, description="Input audio source ID")
audio_processing_template_id: Optional[str] = Field( audio_processing_template_id: str | None = Field(
None, description="Audio processing template ID" None, description="Audio processing template ID"
) )
AudioSourceUpdate = Annotated[ AudioSourceUpdate = Annotated[
Union[ Annotated[CaptureAudioSourceUpdate, Tag("capture")]
Annotated[CaptureAudioSourceUpdate, Tag("capture")], | Annotated[ProcessedAudioSourceUpdate, Tag("processed")],
Annotated[ProcessedAudioSourceUpdate, Tag("processed")],
],
Discriminator("source_type"), Discriminator("source_type"),
] ]
@@ -1,7 +1,7 @@
"""Audio capture template and engine schemas.""" """Audio capture template and engine schemas."""
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional from typing import Dict, List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -14,14 +14,14 @@ class AudioTemplateCreate(BaseModel):
description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1 description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1
) )
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: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -31,17 +31,17 @@ class AudioTemplateCreate(BaseModel):
class AudioTemplateUpdate(BaseModel): class AudioTemplateUpdate(BaseModel):
"""Request to update an audio template.""" """Request to update an audio template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: Optional[str] = Field(None, description="Audio engine type") engine_type: str | None = Field(None, description="Audio engine type")
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration") engine_config: Dict | None = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -58,13 +58,13 @@ class AudioTemplateResponse(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")
description: Optional[str] = Field(None, description="Template description") description: str | None = Field(None, description="Template description")
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
+39 -45
View File
@@ -1,7 +1,7 @@
"""Automation-related schemas.""" """Automation-related schemas."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -11,57 +11,53 @@ class RuleSchema(BaseModel):
rule_type: str = Field(description="Rule type discriminator (e.g. 'application')") rule_type: str = Field(description="Rule type discriminator (e.g. 'application')")
# Application rule fields # Application rule fields
apps: Optional[List[str]] = Field(None, description="Process names (for application rule)") apps: List[str] | None = Field(None, description="Process names (for application rule)")
match_type: Optional[str] = Field( match_type: str | None = Field(
None, description="'running' or 'topmost' (for application rule)" None, description="'running' or 'topmost' (for application rule)"
) )
# Time-of-day rule fields # Time-of-day rule fields
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day rule)") start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)")
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day rule)") end_time: str | None = Field(None, description="End time HH:MM (for time_of_day rule)")
# System idle rule fields # System idle rule fields
idle_minutes: Optional[int] = Field( idle_minutes: int | None = Field(
None, description="Idle timeout in minutes (for system_idle rule)" None, description="Idle timeout in minutes (for system_idle rule)"
) )
when_idle: Optional[bool] = Field( when_idle: bool | None = Field(None, description="True=active when idle (for system_idle rule)")
None, description="True=active when idle (for system_idle rule)"
)
# Display state rule fields # Display state rule fields
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state rule)") state: str | None = Field(None, description="'on' or 'off' (for display_state rule)")
# MQTT rule fields # MQTT rule fields
mqtt_source_id: Optional[str] = Field(None, description="MQTT source ID (for mqtt rule)") mqtt_source_id: str | None = Field(None, description="MQTT source ID (for mqtt rule)")
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt rule)") topic: str | None = Field(None, description="MQTT topic to watch (for mqtt rule)")
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt rule)") payload: str | None = Field(None, description="Expected payload value (for mqtt rule)")
match_mode: Optional[str] = Field( match_mode: str | None = Field(
None, description="'exact', 'contains', or 'regex' (for mqtt rule)" None, description="'exact', 'contains', or 'regex' (for mqtt rule)"
) )
# Webhook rule fields # Webhook rule fields
token: Optional[str] = Field( token: str | None = Field(None, description="Secret token for webhook URL (for webhook rule)")
None, description="Secret token for webhook URL (for webhook rule)"
)
# Home Assistant rule fields # Home Assistant rule fields
ha_source_id: Optional[str] = Field( ha_source_id: str | None = Field(
None, description="Home Assistant source ID (for home_assistant rule)" None, description="Home Assistant source ID (for home_assistant rule)"
) )
entity_id: Optional[str] = Field( entity_id: str | None = Field(
None, None,
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)", description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)",
) )
# HTTP poll rule fields # HTTP poll rule fields
value_source_id: Optional[str] = Field( value_source_id: str | None = Field(
None, None,
description=( description=(
"Value source ID (for http_poll rule). The referenced " "Value source ID (for http_poll rule). The referenced "
"ValueSource must be of source_type='http'." "ValueSource must be of source_type='http'."
), ),
) )
operator: Optional[str] = Field( operator: str | None = Field(
None, None,
description=( description=(
"Comparison operator for http_poll rule: " "Comparison operator for http_poll rule: "
"'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists'." "'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists'."
), ),
) )
value: Optional[str] = Field( value: str | None = Field(
None, description="Expected value (for http_poll rule; ignored for 'exists')" None, description="Expected value (for http_poll rule; ignored for 'exists')"
) )
@@ -77,20 +73,20 @@ class AutomationCreate(BaseModel):
enabled: bool = Field(default=True, description="Whether the automation is enabled") enabled: bool = Field(default=True, description="Whether the automation is enabled")
rule_logic: str = Field(default="or", description="How rules combine: 'or' or 'and'") rule_logic: str = Field(default="or", description="How rules combine: 'or' or 'and'")
rules: List[RuleSchema] = Field(default_factory=list, description="List of rules") rules: List[RuleSchema] = Field(default_factory=list, description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") scene_preset_id: str | None = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field( deactivation_mode: str = Field(
default="none", description="'none', 'revert', or 'fallback_scene'" default="none", description="'none', 'revert', or 'fallback_scene'"
) )
deactivation_scene_preset_id: Optional[str] = Field( deactivation_scene_preset_id: str | None = Field(
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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -100,24 +96,22 @@ class AutomationCreate(BaseModel):
class AutomationUpdate(BaseModel): class AutomationUpdate(BaseModel):
"""Request to update an automation.""" """Request to update an automation."""
name: Optional[str] = Field(None, description="Automation name", min_length=1, max_length=100) name: str | None = Field(None, description="Automation name", min_length=1, max_length=100)
enabled: Optional[bool] = Field(None, description="Whether the automation is enabled") enabled: bool | None = Field(None, description="Whether the automation is enabled")
rule_logic: Optional[str] = Field(None, description="How rules combine: 'or' or 'and'") rule_logic: str | None = Field(None, description="How rules combine: 'or' or 'and'")
rules: Optional[List[RuleSchema]] = Field(None, description="List of rules") rules: List[RuleSchema] | None = Field(None, description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") scene_preset_id: str | None = Field(None, description="Scene preset to activate")
deactivation_mode: Optional[str] = Field( deactivation_mode: str | None = Field(None, description="'none', 'revert', or 'fallback_scene'")
None, description="'none', 'revert', or 'fallback_scene'" deactivation_scene_preset_id: str | None = Field(
)
deactivation_scene_preset_id: Optional[str] = Field(
None, description="Scene preset for fallback deactivation" None, description="Scene preset for fallback deactivation"
) )
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -132,26 +126,26 @@ class AutomationResponse(BaseModel):
enabled: bool = Field(description="Whether the automation is enabled") enabled: bool = Field(description="Whether the automation is enabled")
rule_logic: str = Field(description="Rule combination logic") rule_logic: str = Field(description="Rule combination logic")
rules: List[RuleSchema] = Field(description="List of rules") rules: List[RuleSchema] = Field(description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") scene_preset_id: str | None = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(default="none", description="Deactivation behavior") deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset") deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
webhook_url: Optional[str] = Field( webhook_url: str | None = Field(
None, description="Webhook URL for the first webhook rule (if any)" None, description="Webhook URL for the first webhook rule (if any)"
) )
is_active: bool = Field(default=False, description="Whether the automation is currently active") is_active: bool = Field(default=False, description="Whether the automation is currently active")
last_activated_at: Optional[datetime] = Field( last_activated_at: datetime | None = Field(
None, description="Last time this automation was activated" None, description="Last time this automation was activated"
) )
last_deactivated_at: Optional[datetime] = Field( last_deactivated_at: datetime | None = Field(
None, description="Last time this automation was deactivated" None, description="Last time this automation was deactivated"
) )
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,7 +1,7 @@
"""Color strip processing template schemas.""" """Color strip processing template schemas."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class ColorStripProcessingTemplateCreate(BaseModel):
filters: List[FilterInstanceSchema] = Field( filters: List[FilterInstanceSchema] = Field(
default_factory=list, description="Ordered list of filter instances" default_factory=list, description="Ordered list of filter instances"
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class ColorStripProcessingTemplateCreate(BaseModel):
class ColorStripProcessingTemplateUpdate(BaseModel): class ColorStripProcessingTemplateUpdate(BaseModel):
"""Request to update a color strip processing template.""" """Request to update a color strip processing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field( filters: List[FilterInstanceSchema] | None = Field(
None, description="Ordered list of filter instances" None, description="Ordered list of filter instances"
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -59,13 +59,13 @@ class ColorStripProcessingTemplateResponse(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")
description: Optional[str] = Field(None, description="Template description") description: str | None = Field(None, description="Template description")
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,13 +1,12 @@
"""Color strip source schemas — discriminated unions per source type.""" """Color strip source schemas — discriminated unions per source type."""
from datetime import datetime from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal, Optional, Union from typing import Annotated, Any, Dict, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag, model_validator from pydantic import BaseModel, Discriminator, Field, Tag, model_validator
from ledgrab.api.schemas.devices import Calibration from ledgrab.api.schemas.devices import Calibration
# ===================================================================== # =====================================================================
# Helper models (unchanged) # Helper models (unchanged)
# ===================================================================== # =====================================================================
@@ -16,10 +15,10 @@ from ledgrab.api.schemas.devices import Calibration
class AppSoundOverride(BaseModel): class AppSoundOverride(BaseModel):
"""Per-application sound override for notification sources.""" """Per-application sound override for notification sources."""
sound_asset_id: Optional[str] = Field( sound_asset_id: str | None = Field(
None, description="Asset ID for the sound (None = mute this app)" None, description="Asset ID for the sound (None = mute this app)"
) )
volume: Optional[float] = Field( volume: float | None = Field(
None, ge=0.0, le=1.0, description="Volume override (None = use global)" None, ge=0.0, le=1.0, description="Volume override (None = use global)"
) )
@@ -39,7 +38,7 @@ class ColorStop(BaseModel):
description="Relative position along the strip (0.0-1.0)", ge=0.0, le=1.0 description="Relative position along the strip (0.0-1.0)", ge=0.0, le=1.0
) )
color: List[int] = Field(description="Primary RGB color [R, G, B] (0-255 each)") color: List[int] = Field(description="Primary RGB color [R, G, B] (0-255 each)")
color_right: Optional[List[int]] = Field( color_right: List[int] | None = Field(
None, None,
description="Optional right-side RGB color for a hard edge (bidirectional stop)", description="Optional right-side RGB color for a hard edge (bidirectional stop)",
) )
@@ -54,10 +53,10 @@ class CompositeLayer(BaseModel):
) )
opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0") opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0")
enabled: bool = Field(default=True, description="Whether this layer is active") enabled: bool = Field(default=True, description="Whether this layer is active")
brightness_source_id: Optional[str] = Field( brightness_source_id: str | None = Field(
None, description="Optional value source ID for dynamic brightness" None, description="Optional value source ID for dynamic brightness"
) )
processing_template_id: Optional[str] = Field( processing_template_id: str | None = Field(
None, description="Optional color strip processing template ID" None, description="Optional color strip processing template ID"
) )
start: int = Field(default=0, ge=0, description="First LED index for range (0 = full strip)") start: int = Field(default=0, ge=0, description="First LED index for range (0 = full strip)")
@@ -86,21 +85,21 @@ class _CSSResponseBase(BaseModel):
id: str = Field(description="Source ID") id: str = Field(description="Source ID")
name: str = Field(description="Source name") name: str = Field(description="Source name")
description: Optional[str] = Field(None, description="Description") description: str | None = Field(None, description="Description")
led_count: int = Field(0, description="Total LED count (0 = auto)") led_count: int = Field(0, description="Total LED count (0 = auto)")
overlay_active: bool = Field( overlay_active: bool = Field(
False, description="Whether the screen overlay is currently active" False, description="Whether the screen overlay is currently active"
) )
clock_id: Optional[str] = Field(None, description="Optional sync clock ID") clock_id: str | None = 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")
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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -112,40 +111,40 @@ class PictureCSSResponse(_CSSResponseBase):
picture_source_id: str = Field(description="Picture source ID") picture_source_id: str = Field(description="Picture source ID")
smoothing: Any = Field(description="Temporal smoothing") smoothing: Any = Field(description="Temporal smoothing")
interpolation_mode: str = Field(description="Interpolation mode") interpolation_mode: str = Field(description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration") calibration: Calibration | None = Field(None, description="LED calibration")
class PictureAdvancedCSSResponse(_CSSResponseBase): class PictureAdvancedCSSResponse(_CSSResponseBase):
source_type: Literal["picture_advanced"] = "picture_advanced" source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(description="Temporal smoothing") smoothing: Any = Field(description="Temporal smoothing")
interpolation_mode: str = Field(description="Interpolation mode") interpolation_mode: str = Field(description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration") calibration: Calibration | None = Field(None, description="LED calibration")
class SingleColorCSSResponse(_CSSResponseBase): class SingleColorCSSResponse(_CSSResponseBase):
source_type: Literal["single_color"] = "single_color" source_type: Literal["single_color"] = "single_color"
color: Any = Field(description="Solid RGB color") color: Any = Field(description="Solid RGB color")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") animation: AnimationConfig | None = Field(None, description="Procedural animation config")
class GradientCSSResponse(_CSSResponseBase): class GradientCSSResponse(_CSSResponseBase):
source_type: Literal["gradient"] = "gradient" source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops") stops: List[ColorStop] | None = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") animation: AnimationConfig | None = Field(None, description="Procedural animation config")
easing: str = Field(description="Gradient interpolation easing") easing: str = Field(description="Gradient interpolation easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
class EffectCSSResponse(_CSSResponseBase): class EffectCSSResponse(_CSSResponseBase):
source_type: Literal["effect"] = "effect" source_type: Literal["effect"] = "effect"
effect_type: str = Field(description="Effect algorithm") effect_type: str = Field(description="Effect algorithm")
palette: str = Field(description="Named palette") palette: str = Field(description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(description="Primary color") color: Any = Field(description="Primary color")
intensity: Any = Field(description="Effect intensity") intensity: Any = Field(description="Effect intensity")
scale: Any = Field(description="Spatial scale") scale: Any = Field(description="Spatial scale")
mirror: bool = Field(description="Mirror/bounce mode") mirror: bool = Field(description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops") custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
class CompositeCSSResponse(_CSSResponseBase): class CompositeCSSResponse(_CSSResponseBase):
@@ -165,7 +164,7 @@ class AudioCSSResponse(_CSSResponseBase):
sensitivity: Any = Field(description="Audio sensitivity") sensitivity: Any = Field(description="Audio sensitivity")
smoothing: Any = Field(description="Temporal smoothing") smoothing: Any = Field(description="Temporal smoothing")
palette: str = Field(description="Named palette") palette: str = Field(description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(description="Primary color") color: Any = Field(description="Primary color")
color_peak: Any = Field(description="Peak color") color_peak: Any = Field(description="Peak color")
mirror: bool = Field(description="Mirror mode") mirror: bool = Field(description="Mirror mode")
@@ -188,7 +187,7 @@ class NotificationCSSResponse(_CSSResponseBase):
app_filter_mode: str = Field(description="App filter mode") app_filter_mode: str = Field(description="App filter mode")
app_filter_list: List[str] = Field(default_factory=list, description="App names for filter") app_filter_list: List[str] = Field(default_factory=list, description="App names for filter")
os_listener: bool = Field(description="Whether to listen for OS notifications") os_listener: bool = Field(description="Whether to listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID") sound_asset_id: str | None = Field(None, description="Global notification sound asset ID")
sound_volume: Any = Field(description="Global notification sound volume") sound_volume: Any = Field(description="Global notification sound volume")
app_sounds: Dict[str, dict] = Field(default_factory=dict, description="Per-app sound overrides") app_sounds: Dict[str, dict] = Field(default_factory=dict, description="Per-app sound overrides")
@@ -237,7 +236,7 @@ class MathWaveCSSResponse(_CSSResponseBase):
source_type: Literal["math_wave"] = "math_wave" source_type: Literal["math_wave"] = "math_wave"
waves: List[dict] = Field(description="Wave layer definitions") waves: List[dict] = Field(description="Wave layer definitions")
speed: Any = Field(description="Global speed multiplier (bindable)") speed: Any = Field(description="Global speed multiplier (bindable)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping") gradient_id: str | None = Field(None, description="Gradient entity ID for color mapping")
class GameEventCSSResponse(_CSSResponseBase): class GameEventCSSResponse(_CSSResponseBase):
@@ -248,25 +247,23 @@ class GameEventCSSResponse(_CSSResponseBase):
ColorStripSourceResponse = Annotated[ ColorStripSourceResponse = Annotated[
Union[ Annotated[PictureCSSResponse, Tag("picture")]
Annotated[PictureCSSResponse, Tag("picture")], | Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")]
Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")], | Annotated[SingleColorCSSResponse, Tag("single_color")]
Annotated[SingleColorCSSResponse, Tag("single_color")], | Annotated[GradientCSSResponse, Tag("gradient")]
Annotated[GradientCSSResponse, Tag("gradient")], | Annotated[EffectCSSResponse, Tag("effect")]
Annotated[EffectCSSResponse, Tag("effect")], | Annotated[CompositeCSSResponse, Tag("composite")]
Annotated[CompositeCSSResponse, Tag("composite")], | Annotated[MappedCSSResponse, Tag("mapped")]
Annotated[MappedCSSResponse, Tag("mapped")], | Annotated[AudioCSSResponse, Tag("audio")]
Annotated[AudioCSSResponse, Tag("audio")], | Annotated[ApiInputCSSResponse, Tag("api_input")]
Annotated[ApiInputCSSResponse, Tag("api_input")], | Annotated[NotificationCSSResponse, Tag("notification")]
Annotated[NotificationCSSResponse, Tag("notification")], | Annotated[DaylightCSSResponse, Tag("daylight")]
Annotated[DaylightCSSResponse, Tag("daylight")], | Annotated[CandlelightCSSResponse, Tag("candlelight")]
Annotated[CandlelightCSSResponse, Tag("candlelight")], | Annotated[ProcessedCSSResponse, Tag("processed")]
Annotated[ProcessedCSSResponse, Tag("processed")], | Annotated[WeatherCSSResponse, Tag("weather")]
Annotated[WeatherCSSResponse, Tag("weather")], | Annotated[KeyColorsCSSResponse, Tag("key_colors")]
Annotated[KeyColorsCSSResponse, Tag("key_colors")], | Annotated[MathWaveCSSResponse, Tag("math_wave")]
Annotated[MathWaveCSSResponse, Tag("math_wave")], | Annotated[GameEventCSSResponse, Tag("game_event")],
Annotated[GameEventCSSResponse, Tag("game_event")],
],
Discriminator("source_type"), Discriminator("source_type"),
] ]
@@ -281,15 +278,15 @@ class _CSSCreateBase(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)
led_count: int = Field(default=0, description="Total LED count (0 = auto)", ge=0) led_count: int = Field(default=0, description="Total LED count (0 = auto)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID") clock_id: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -301,63 +298,63 @@ class PictureCSSCreate(_CSSCreateBase):
picture_source_id: str = Field(default="", description="Picture source ID") picture_source_id: str = Field(default="", description="Picture source ID")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: str = Field(default="average", description="Interpolation mode") interpolation_mode: str = Field(default="average", description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration") calibration: Calibration | None = Field(None, description="LED calibration")
class PictureAdvancedCSSCreate(_CSSCreateBase): class PictureAdvancedCSSCreate(_CSSCreateBase):
source_type: Literal["picture_advanced"] = "picture_advanced" source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: str = Field(default="average", description="Interpolation mode") interpolation_mode: str = Field(default="average", description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration") calibration: Calibration | None = Field(None, description="LED calibration")
class SingleColorCSSCreate(_CSSCreateBase): class SingleColorCSSCreate(_CSSCreateBase):
source_type: Literal["single_color"] = "single_color" source_type: Literal["single_color"] = "single_color"
color: Any = Field(default=None, description="Solid RGB color [R,G,B]") color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") animation: AnimationConfig | None = Field(None, description="Procedural animation config")
class GradientCSSCreate(_CSSCreateBase): class GradientCSSCreate(_CSSCreateBase):
source_type: Literal["gradient"] = "gradient" source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops") stops: List[ColorStop] | None = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") animation: AnimationConfig | None = Field(None, description="Procedural animation config")
easing: Optional[str] = Field(None, description="Gradient easing") easing: str | None = Field(None, description="Gradient easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
class EffectCSSCreate(_CSSCreateBase): class EffectCSSCreate(_CSSCreateBase):
source_type: Literal["effect"] = "effect" source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm") effect_type: str | None = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette") palette: str | None = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color") color: Any = Field(default=None, description="Primary color")
intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)") intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)")
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)") scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode") mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops") custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
class CompositeCSSCreate(_CSSCreateBase): class CompositeCSSCreate(_CSSCreateBase):
source_type: Literal["composite"] = "composite" source_type: Literal["composite"] = "composite"
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") layers: List[CompositeLayer] | None = Field(None, description="Layers for composite type")
class MappedCSSCreate(_CSSCreateBase): class MappedCSSCreate(_CSSCreateBase):
source_type: Literal["mapped"] = "mapped" source_type: Literal["mapped"] = "mapped"
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type") zones: List[MappedZone] | None = Field(None, description="Zones for mapped type")
class AudioCSSCreate(_CSSCreateBase): class AudioCSSCreate(_CSSCreateBase):
source_type: Literal["audio"] = "audio" source_type: Literal["audio"] = "audio"
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode") visualization_mode: str | None = Field(None, description="Audio visualization mode")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID") audio_source_id: str | None = Field(None, description="Mono audio source ID")
sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)") sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
palette: Optional[str] = Field(None, description="Named palette") palette: str | None = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color") color: Any = Field(default=None, description="Primary color")
color_peak: Any = Field(default=None, description="Peak color [R,G,B]") color_peak: Any = Field(default=None, description="Peak color [R,G,B]")
mirror: Optional[bool] = Field(None, description="Mirror mode") mirror: bool | None = Field(None, description="Mirror mode")
beat_decay: Any = Field( beat_decay: Any = Field(
default=None, description="Beat pulse decay rate (music modes, 0.01-0.5)" default=None, description="Beat pulse decay rate (music modes, 0.01-0.5)"
) )
@@ -367,23 +364,23 @@ class ApiInputCSSCreate(_CSSCreateBase):
source_type: Literal["api_input"] = "api_input" source_type: Literal["api_input"] = "api_input"
fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]") fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]")
timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)") timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)")
interpolation: Optional[str] = Field(None, description="LED count interpolation mode") interpolation: str | None = Field(None, description="LED count interpolation mode")
class NotificationCSSCreate(_CSSCreateBase): class NotificationCSSCreate(_CSSCreateBase):
source_type: Literal["notification"] = "notification" source_type: Literal["notification"] = "notification"
notification_effect: Optional[str] = Field(None, description="Notification effect") notification_effect: str | None = Field(None, description="Notification effect")
duration_ms: Any = Field(default=None, description="Effect duration in milliseconds") duration_ms: Any = Field(default=None, description="Effect duration in milliseconds")
default_color: Optional[Union[List[int], Dict[str, Any], str]] = Field( default_color: List[int] | Dict[str, Any] | str | None = Field(
None, description="Default color" None, description="Default color"
) )
app_colors: Optional[Dict[str, str]] = Field(None, description="Per-app hex colors") app_colors: Dict[str, str] | None = Field(None, description="Per-app hex colors")
app_filter_mode: Optional[str] = Field(None, description="App filter mode") app_filter_mode: str | None = Field(None, description="App filter mode")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter") app_filter_list: List[str] | None = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Listen for OS notifications") os_listener: bool | None = Field(None, description="Listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID") sound_asset_id: str | None = Field(None, description="Global notification sound asset ID")
sound_volume: Any = Field(default=None, description="Global notification sound volume") sound_volume: Any = Field(default=None, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field( app_sounds: Dict[str, AppSoundOverride] | None = Field(
None, description="Per-app sound overrides" None, description="Per-app sound overrides"
) )
@@ -391,9 +388,9 @@ class NotificationCSSCreate(_CSSCreateBase):
class DaylightCSSCreate(_CSSCreateBase): class DaylightCSSCreate(_CSSCreateBase):
source_type: Literal["daylight"] = "daylight" source_type: Literal["daylight"] = "daylight"
speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)") speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time") use_real_time: bool | None = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0) latitude: float | None = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field( longitude: float | None = Field(
None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0 None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0
) )
@@ -402,23 +399,23 @@ class CandlelightCSSCreate(_CSSCreateBase):
source_type: Literal["candlelight"] = "candlelight" source_type: Literal["candlelight"] = "candlelight"
color: Any = Field(default=None, description="Candle color [R,G,B]") color: Any = Field(default=None, description="Candle color [R,G,B]")
intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)") intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)")
num_candles: Optional[int] = Field( num_candles: int | None = Field(
None, description="Number of candle sources (1-20)", ge=1, le=20 None, description="Number of candle sources (1-20)", ge=1, le=20
) )
speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)") speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)")
wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)") wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)")
candle_type: Optional[str] = Field(None, description="Candle type preset") candle_type: str | None = Field(None, description="Candle type preset")
class ProcessedCSSCreate(_CSSCreateBase): class ProcessedCSSCreate(_CSSCreateBase):
source_type: Literal["processed"] = "processed" source_type: Literal["processed"] = "processed"
input_source_id: Optional[str] = Field(None, description="Input color strip source ID") input_source_id: str | None = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Processing template ID") processing_template_id: str | None = Field(None, description="Processing template ID")
class WeatherCSSCreate(_CSSCreateBase): class WeatherCSSCreate(_CSSCreateBase):
source_type: Literal["weather"] = "weather" source_type: Literal["weather"] = "weather"
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID") weather_source_id: str | None = Field(None, description="Weather source entity ID")
speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)") speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)")
temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)") temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)")
@@ -426,49 +423,47 @@ class WeatherCSSCreate(_CSSCreateBase):
class KeyColorsCSSCreate(_CSSCreateBase): class KeyColorsCSSCreate(_CSSCreateBase):
source_type: Literal["key_colors"] = "key_colors" source_type: Literal["key_colors"] = "key_colors"
picture_source_id: str = Field(default="", description="Picture source ID") picture_source_id: str = Field(default="", description="Picture source ID")
rectangles: Optional[List[dict]] = Field(None, description="Named screen regions") rectangles: List[dict] | None = Field(None, description="Named screen regions")
interpolation_mode: str = Field(default="average", description="Interpolation mode") interpolation_mode: str = Field(default="average", description="Interpolation mode")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
brightness: Any = Field(default=None, description="Brightness (0.0-1.0)") brightness: Any = Field(default=None, description="Brightness (0.0-1.0)")
brightness_value_source_id: Optional[str] = Field( brightness_value_source_id: str | None = Field(
None, description="Dynamic brightness value source ID" None, description="Dynamic brightness value source ID"
) )
class MathWaveCSSCreate(_CSSCreateBase): class MathWaveCSSCreate(_CSSCreateBase):
source_type: Literal["math_wave"] = "math_wave" source_type: Literal["math_wave"] = "math_wave"
waves: Optional[List[dict]] = Field(None, description="Wave layer definitions") waves: List[dict] | None = Field(None, description="Wave layer definitions")
speed: Any = Field(default=None, description="Global speed multiplier (bindable, 0.1-10.0)") speed: Any = Field(default=None, description="Global speed multiplier (bindable, 0.1-10.0)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping") gradient_id: str | None = Field(None, description="Gradient entity ID for color mapping")
class GameEventCSSCreate(_CSSCreateBase): class GameEventCSSCreate(_CSSCreateBase):
source_type: Literal["game_event"] = "game_event" source_type: Literal["game_event"] = "game_event"
game_integration_id: Optional[str] = Field(None, description="Game integration entity ID") game_integration_id: str | None = Field(None, description="Game integration entity ID")
idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)") idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)")
event_mappings: Optional[List[dict]] = Field(None, description="Event-to-effect mappings") event_mappings: List[dict] | None = Field(None, description="Event-to-effect mappings")
ColorStripSourceCreate = Annotated[ ColorStripSourceCreate = Annotated[
Union[ Annotated[PictureCSSCreate, Tag("picture")]
Annotated[PictureCSSCreate, Tag("picture")], | Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")]
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")], | Annotated[SingleColorCSSCreate, Tag("single_color")]
Annotated[SingleColorCSSCreate, Tag("single_color")], | Annotated[GradientCSSCreate, Tag("gradient")]
Annotated[GradientCSSCreate, Tag("gradient")], | Annotated[EffectCSSCreate, Tag("effect")]
Annotated[EffectCSSCreate, Tag("effect")], | Annotated[CompositeCSSCreate, Tag("composite")]
Annotated[CompositeCSSCreate, Tag("composite")], | Annotated[MappedCSSCreate, Tag("mapped")]
Annotated[MappedCSSCreate, Tag("mapped")], | Annotated[AudioCSSCreate, Tag("audio")]
Annotated[AudioCSSCreate, Tag("audio")], | Annotated[ApiInputCSSCreate, Tag("api_input")]
Annotated[ApiInputCSSCreate, Tag("api_input")], | Annotated[NotificationCSSCreate, Tag("notification")]
Annotated[NotificationCSSCreate, Tag("notification")], | Annotated[DaylightCSSCreate, Tag("daylight")]
Annotated[DaylightCSSCreate, Tag("daylight")], | Annotated[CandlelightCSSCreate, Tag("candlelight")]
Annotated[CandlelightCSSCreate, Tag("candlelight")], | Annotated[ProcessedCSSCreate, Tag("processed")]
Annotated[ProcessedCSSCreate, Tag("processed")], | Annotated[WeatherCSSCreate, Tag("weather")]
Annotated[WeatherCSSCreate, Tag("weather")], | Annotated[KeyColorsCSSCreate, Tag("key_colors")]
Annotated[KeyColorsCSSCreate, Tag("key_colors")], | Annotated[MathWaveCSSCreate, Tag("math_wave")]
Annotated[MathWaveCSSCreate, Tag("math_wave")], | Annotated[GameEventCSSCreate, Tag("game_event")],
Annotated[GameEventCSSCreate, Tag("game_event")],
],
Discriminator("source_type"), Discriminator("source_type"),
] ]
@@ -481,17 +476,17 @@ ColorStripSourceCreate = Annotated[
class _CSSUpdateBase(BaseModel): class _CSSUpdateBase(BaseModel):
"""Shared fields for all color strip source update requests.""" """Shared fields for all color strip source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto)", ge=0) led_count: int | None = Field(None, description="Total LED count (0 = auto)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID") clock_id: str | None = Field(None, description="Optional sync clock ID")
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -500,66 +495,66 @@ class _CSSUpdateBase(BaseModel):
class PictureCSSUpdate(_CSSUpdateBase): class PictureCSSUpdate(_CSSUpdateBase):
source_type: Literal["picture"] = "picture" source_type: Literal["picture"] = "picture"
picture_source_id: Optional[str] = Field(None, description="Picture source ID") picture_source_id: str | None = Field(None, description="Picture source ID")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") interpolation_mode: str | None = Field(None, description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration") calibration: Calibration | None = Field(None, description="LED calibration")
class PictureAdvancedCSSUpdate(_CSSUpdateBase): class PictureAdvancedCSSUpdate(_CSSUpdateBase):
source_type: Literal["picture_advanced"] = "picture_advanced" source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") interpolation_mode: str | None = Field(None, description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration") calibration: Calibration | None = Field(None, description="LED calibration")
class SingleColorCSSUpdate(_CSSUpdateBase): class SingleColorCSSUpdate(_CSSUpdateBase):
source_type: Literal["single_color"] = "single_color" source_type: Literal["single_color"] = "single_color"
color: Any = Field(default=None, description="Solid RGB color [R,G,B]") color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") animation: AnimationConfig | None = Field(None, description="Procedural animation config")
class GradientCSSUpdate(_CSSUpdateBase): class GradientCSSUpdate(_CSSUpdateBase):
source_type: Literal["gradient"] = "gradient" source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops") stops: List[ColorStop] | None = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config") animation: AnimationConfig | None = Field(None, description="Procedural animation config")
easing: Optional[str] = Field(None, description="Gradient easing") easing: str | None = Field(None, description="Gradient easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
class EffectCSSUpdate(_CSSUpdateBase): class EffectCSSUpdate(_CSSUpdateBase):
source_type: Literal["effect"] = "effect" source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm") effect_type: str | None = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette") palette: str | None = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color") color: Any = Field(default=None, description="Primary color")
intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)") intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)")
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)") scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode") mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops") custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
class CompositeCSSUpdate(_CSSUpdateBase): class CompositeCSSUpdate(_CSSUpdateBase):
source_type: Literal["composite"] = "composite" source_type: Literal["composite"] = "composite"
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") layers: List[CompositeLayer] | None = Field(None, description="Layers for composite type")
class MappedCSSUpdate(_CSSUpdateBase): class MappedCSSUpdate(_CSSUpdateBase):
source_type: Literal["mapped"] = "mapped" source_type: Literal["mapped"] = "mapped"
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type") zones: List[MappedZone] | None = Field(None, description="Zones for mapped type")
class AudioCSSUpdate(_CSSUpdateBase): class AudioCSSUpdate(_CSSUpdateBase):
source_type: Literal["audio"] = "audio" source_type: Literal["audio"] = "audio"
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode") visualization_mode: str | None = Field(None, description="Audio visualization mode")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID") audio_source_id: str | None = Field(None, description="Mono audio source ID")
sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)") sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
palette: Optional[str] = Field(None, description="Named palette") palette: str | None = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color") color: Any = Field(default=None, description="Primary color")
color_peak: Any = Field(default=None, description="Peak color [R,G,B]") color_peak: Any = Field(default=None, description="Peak color [R,G,B]")
mirror: Optional[bool] = Field(None, description="Mirror mode") mirror: bool | None = Field(None, description="Mirror mode")
beat_decay: Any = Field(default=None, description="Beat pulse decay rate (music modes)") beat_decay: Any = Field(default=None, description="Beat pulse decay rate (music modes)")
@@ -567,23 +562,23 @@ class ApiInputCSSUpdate(_CSSUpdateBase):
source_type: Literal["api_input"] = "api_input" source_type: Literal["api_input"] = "api_input"
fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]") fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]")
timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)") timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)")
interpolation: Optional[str] = Field(None, description="LED count interpolation mode") interpolation: str | None = Field(None, description="LED count interpolation mode")
class NotificationCSSUpdate(_CSSUpdateBase): class NotificationCSSUpdate(_CSSUpdateBase):
source_type: Literal["notification"] = "notification" source_type: Literal["notification"] = "notification"
notification_effect: Optional[str] = Field(None, description="Notification effect") notification_effect: str | None = Field(None, description="Notification effect")
duration_ms: Any = Field(default=None, description="Effect duration in milliseconds") duration_ms: Any = Field(default=None, description="Effect duration in milliseconds")
default_color: Optional[Union[List[int], Dict[str, Any], str]] = Field( default_color: List[int] | Dict[str, Any] | str | None = Field(
None, description="Default color" None, description="Default color"
) )
app_colors: Optional[Dict[str, str]] = Field(None, description="Per-app hex colors") app_colors: Dict[str, str] | None = Field(None, description="Per-app hex colors")
app_filter_mode: Optional[str] = Field(None, description="App filter mode") app_filter_mode: str | None = Field(None, description="App filter mode")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter") app_filter_list: List[str] | None = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Listen for OS notifications") os_listener: bool | None = Field(None, description="Listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID") sound_asset_id: str | None = Field(None, description="Global notification sound asset ID")
sound_volume: Any = Field(default=None, description="Global notification sound volume") sound_volume: Any = Field(default=None, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field( app_sounds: Dict[str, AppSoundOverride] | None = Field(
None, description="Per-app sound overrides" None, description="Per-app sound overrides"
) )
@@ -591,9 +586,9 @@ class NotificationCSSUpdate(_CSSUpdateBase):
class DaylightCSSUpdate(_CSSUpdateBase): class DaylightCSSUpdate(_CSSUpdateBase):
source_type: Literal["daylight"] = "daylight" source_type: Literal["daylight"] = "daylight"
speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)") speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time") use_real_time: bool | None = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0) latitude: float | None = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field( longitude: float | None = Field(
None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0 None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0
) )
@@ -602,73 +597,71 @@ class CandlelightCSSUpdate(_CSSUpdateBase):
source_type: Literal["candlelight"] = "candlelight" source_type: Literal["candlelight"] = "candlelight"
color: Any = Field(default=None, description="Candle color [R,G,B]") color: Any = Field(default=None, description="Candle color [R,G,B]")
intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)") intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)")
num_candles: Optional[int] = Field( num_candles: int | None = Field(
None, description="Number of candle sources (1-20)", ge=1, le=20 None, description="Number of candle sources (1-20)", ge=1, le=20
) )
speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)") speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)")
wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)") wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)")
candle_type: Optional[str] = Field(None, description="Candle type preset") candle_type: str | None = Field(None, description="Candle type preset")
class ProcessedCSSUpdate(_CSSUpdateBase): class ProcessedCSSUpdate(_CSSUpdateBase):
source_type: Literal["processed"] = "processed" source_type: Literal["processed"] = "processed"
input_source_id: Optional[str] = Field(None, description="Input color strip source ID") input_source_id: str | None = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Processing template ID") processing_template_id: str | None = Field(None, description="Processing template ID")
class WeatherCSSUpdate(_CSSUpdateBase): class WeatherCSSUpdate(_CSSUpdateBase):
source_type: Literal["weather"] = "weather" source_type: Literal["weather"] = "weather"
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID") weather_source_id: str | None = Field(None, description="Weather source entity ID")
speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)") speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)")
temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)") temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)")
class KeyColorsCSSUpdate(_CSSUpdateBase): class KeyColorsCSSUpdate(_CSSUpdateBase):
source_type: Literal["key_colors"] = "key_colors" source_type: Literal["key_colors"] = "key_colors"
picture_source_id: Optional[str] = Field(None, description="Picture source ID") picture_source_id: str | None = Field(None, description="Picture source ID")
rectangles: Optional[List[dict]] = Field(None, description="Named screen regions") rectangles: List[dict] | None = Field(None, description="Named screen regions")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") interpolation_mode: str | None = Field(None, description="Interpolation mode")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)") smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
brightness: Any = Field(default=None, description="Brightness (0.0-1.0)") brightness: Any = Field(default=None, description="Brightness (0.0-1.0)")
brightness_value_source_id: Optional[str] = Field( brightness_value_source_id: str | None = Field(
None, description="Dynamic brightness value source ID" None, description="Dynamic brightness value source ID"
) )
class MathWaveCSSUpdate(_CSSUpdateBase): class MathWaveCSSUpdate(_CSSUpdateBase):
source_type: Literal["math_wave"] = "math_wave" source_type: Literal["math_wave"] = "math_wave"
waves: Optional[List[dict]] = Field(None, description="Wave layer definitions") waves: List[dict] | None = Field(None, description="Wave layer definitions")
speed: Any = Field(default=None, description="Global speed multiplier (bindable)") speed: Any = Field(default=None, description="Global speed multiplier (bindable)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping") gradient_id: str | None = Field(None, description="Gradient entity ID for color mapping")
class GameEventCSSUpdate(_CSSUpdateBase): class GameEventCSSUpdate(_CSSUpdateBase):
source_type: Literal["game_event"] = "game_event" source_type: Literal["game_event"] = "game_event"
game_integration_id: Optional[str] = Field(None, description="Game integration entity ID") game_integration_id: str | None = Field(None, description="Game integration entity ID")
idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)") idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)")
event_mappings: Optional[List[dict]] = Field(None, description="Event-to-effect mappings") event_mappings: List[dict] | None = Field(None, description="Event-to-effect mappings")
ColorStripSourceUpdate = Annotated[ ColorStripSourceUpdate = Annotated[
Union[ Annotated[PictureCSSUpdate, Tag("picture")]
Annotated[PictureCSSUpdate, Tag("picture")], | Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")]
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")], | Annotated[SingleColorCSSUpdate, Tag("single_color")]
Annotated[SingleColorCSSUpdate, Tag("single_color")], | Annotated[GradientCSSUpdate, Tag("gradient")]
Annotated[GradientCSSUpdate, Tag("gradient")], | Annotated[EffectCSSUpdate, Tag("effect")]
Annotated[EffectCSSUpdate, Tag("effect")], | Annotated[CompositeCSSUpdate, Tag("composite")]
Annotated[CompositeCSSUpdate, Tag("composite")], | Annotated[MappedCSSUpdate, Tag("mapped")]
Annotated[MappedCSSUpdate, Tag("mapped")], | Annotated[AudioCSSUpdate, Tag("audio")]
Annotated[AudioCSSUpdate, Tag("audio")], | Annotated[ApiInputCSSUpdate, Tag("api_input")]
Annotated[ApiInputCSSUpdate, Tag("api_input")], | Annotated[NotificationCSSUpdate, Tag("notification")]
Annotated[NotificationCSSUpdate, Tag("notification")], | Annotated[DaylightCSSUpdate, Tag("daylight")]
Annotated[DaylightCSSUpdate, Tag("daylight")], | Annotated[CandlelightCSSUpdate, Tag("candlelight")]
Annotated[CandlelightCSSUpdate, Tag("candlelight")], | Annotated[ProcessedCSSUpdate, Tag("processed")]
Annotated[ProcessedCSSUpdate, Tag("processed")], | Annotated[WeatherCSSUpdate, Tag("weather")]
Annotated[WeatherCSSUpdate, Tag("weather")], | Annotated[KeyColorsCSSUpdate, Tag("key_colors")]
Annotated[KeyColorsCSSUpdate, Tag("key_colors")], | Annotated[MathWaveCSSUpdate, Tag("math_wave")]
Annotated[MathWaveCSSUpdate, Tag("math_wave")], | Annotated[GameEventCSSUpdate, Tag("game_event")],
Annotated[GameEventCSSUpdate, Tag("game_event")],
],
Discriminator("source_type"), Discriminator("source_type"),
] ]
@@ -699,17 +692,17 @@ class SegmentPayload(BaseModel):
``color`` therefore fills the entire strip. ``color`` therefore fills the entire strip.
""" """
start: Optional[int] = Field( start: int | None = Field(
None, ge=0, description="Starting LED index (default 0 = beginning of strip)" None, ge=0, description="Starting LED index (default 0 = beginning of strip)"
) )
length: Optional[int] = Field( length: int | None = Field(
None, None,
ge=1, ge=1,
description="Number of LEDs in segment (default = led_count - start)", description="Number of LEDs in segment (default = led_count - start)",
) )
mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode") mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode")
color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]") color: List[int] | None = Field(None, description="RGB for solid mode [R,G,B]")
colors: Optional[List[List[int]]] = Field( colors: List[List[int]] | None = Field(
None, description="Colors for per_pixel/gradient [[R,G,B],...]" None, description="Colors for per_pixel/gradient [[R,G,B],...]"
) )
@@ -742,12 +735,10 @@ class ColorPushRequest(BaseModel):
At least one must be provided. At least one must be provided.
""" """
colors: Optional[List[List[int]]] = Field( colors: List[List[int]] | None = Field(
None, description="LED color array [[R,G,B], ...] (0-255 each)" None, description="LED color array [[R,G,B], ...] (0-255 each)"
) )
segments: Optional[List[SegmentPayload]] = Field( segments: List[SegmentPayload] | None = Field(None, description="Segment-based color updates")
None, description="Segment-based color updates"
)
@model_validator(mode="after") @model_validator(mode="after")
def _require_colors_or_segments(self) -> "ColorPushRequest": def _require_colors_or_segments(self) -> "ColorPushRequest":
@@ -759,8 +750,8 @@ class ColorPushRequest(BaseModel):
class NotifyRequest(BaseModel): class NotifyRequest(BaseModel):
"""Request to trigger a notification on a notification color strip source.""" """Request to trigger a notification on a notification color strip source."""
app: Optional[str] = Field(None, description="App name for color lookup") app: str | None = Field(None, description="App name for color lookup")
color: Optional[str] = Field(None, description="Hex color override (#RRGGBB)") color: str | None = Field(None, description="Hex color override (#RRGGBB)")
class CSSCalibrationTestRequest(BaseModel): class CSSCalibrationTestRequest(BaseModel):
+6 -6
View File
@@ -1,7 +1,7 @@
"""Shared schemas used across multiple route modules.""" """Shared schemas used across multiple route modules."""
from datetime import datetime from datetime import datetime
from typing import Dict, Optional from typing import Dict
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -11,7 +11,7 @@ class ErrorResponse(BaseModel):
error: str = Field(description="Error type") error: str = Field(description="Error type")
message: str = Field(description="Error message") message: str = Field(description="Error message")
detail: Optional[Dict] = Field(None, description="Additional error details") detail: Dict | None = Field(None, description="Additional error details")
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Error timestamp") timestamp: datetime = Field(default_factory=datetime.utcnow, description="Error timestamp")
@@ -19,11 +19,11 @@ class CaptureImage(BaseModel):
"""Captured image with metadata.""" """Captured image with metadata."""
image: str = Field(description="Base64-encoded thumbnail image data") image: str = Field(description="Base64-encoded thumbnail image data")
full_image: Optional[str] = Field(None, description="Base64-encoded full-resolution image data") full_image: str | None = Field(None, description="Base64-encoded full-resolution image data")
width: int = Field(description="Original image width in pixels") width: int = Field(description="Original image width in pixels")
height: int = Field(description="Original image height in pixels") height: int = Field(description="Original image height in pixels")
thumbnail_width: Optional[int] = Field(None, description="Thumbnail width (if resized)") thumbnail_width: int | None = Field(None, description="Thumbnail width (if resized)")
thumbnail_height: Optional[int] = Field(None, description="Thumbnail height (if resized)") thumbnail_height: int | None = Field(None, description="Thumbnail height (if resized)")
class BorderExtraction(BaseModel): class BorderExtraction(BaseModel):
@@ -48,7 +48,7 @@ class TemplateTestResponse(BaseModel):
"""Response from template test.""" """Response from template test."""
full_capture: CaptureImage = Field(description="Full screen capture with thumbnail") full_capture: CaptureImage = Field(description="Full screen capture with thumbnail")
border_extraction: Optional[BorderExtraction] = Field( border_extraction: BorderExtraction | None = Field(
None, description="Extracted border images (deprecated)" None, description="Extracted border images (deprecated)"
) )
performance: PerformanceMetrics = Field(description="Performance metrics") performance: PerformanceMetrics = Field(description="Performance metrics")
+90 -100
View File
@@ -1,7 +1,7 @@
"""Device-related schemas (CRUD, calibration, device state).""" """Device-related schemas (CRUD, calibration, device state)."""
from datetime import datetime from datetime import datetime
from typing import Dict, List, Literal, Optional from typing import Dict, List, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -10,149 +10,145 @@ class DeviceCreate(BaseModel):
"""Request to create/attach an LED device.""" """Request to create/attach an LED device."""
name: str = Field(description="Device name", min_length=1, max_length=100) name: str = Field(description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field( url: str | None = Field(
None, None,
description="Device URL (e.g., http://192.168.1.100 or COM3). Not required for group devices.", description="Device URL (e.g., http://192.168.1.100 or COM3). Not required for group devices.",
) )
device_type: str = Field(default="wled", description="LED device type (e.g., wled, adalight)") device_type: str = Field(default="wled", description="LED device type (e.g., wled, adalight)")
led_count: Optional[int] = Field( led_count: int | None = Field(
None, ge=1, le=10000, description="Number of LEDs (required for adalight)" None, ge=1, le=10000, description="Number of LEDs (required for adalight)"
) )
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)") baud_rate: int | None = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: Optional[bool] = Field( auto_shutdown: bool | None = Field(
default=None, default=None,
description="Turn off device when server stops (defaults to true for adalight)", description="Turn off device when server stops (defaults to true for adalight)",
) )
send_latency_ms: Optional[int] = Field( send_latency_ms: int | None = Field(
None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)" None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)"
) )
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)") rgbw: bool | None = Field(None, description="RGBW mode (mock devices)")
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate") zone_mode: str | None = Field(None, description="OpenRGB zone mode: combined or separate")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
# DMX (Art-Net / sACN) fields # DMX (Art-Net / sACN) fields
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn") dmx_protocol: str | None = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field( dmx_start_universe: int | None = Field(None, ge=0, le=32767, description="DMX start universe")
None, ge=0, le=32767, description="DMX start universe" dmx_start_channel: int | None = Field(
)
dmx_start_channel: Optional[int] = Field(
None, ge=1, le=512, description="DMX start channel (1-512)" None, ge=1, le=512, description="DMX start channel (1-512)"
) )
# DDP fields # DDP fields
ddp_port: Optional[int] = Field( ddp_port: int | None = Field(
None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)" None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)"
) )
ddp_destination_id: Optional[int] = Field( ddp_destination_id: int | None = Field(
None, ge=0, le=255, description="DDP destination ID (default 1 = display)" None, ge=0, le=255, description="DDP destination ID (default 1 = display)"
) )
ddp_color_order: Optional[int] = Field( ddp_color_order: int | None = Field(
None, None,
ge=0, ge=0,
le=5, le=5,
description="DDP color order: 0=GRB 1=RGB 2=BRG 3=RBG 4=BGR 5=GBR (most receivers expect RGB)", description="DDP color order: 0=GRB 1=RGB 2=BRG 3=RBG 4=BGR 5=GBR (most receivers expect RGB)",
) )
# ESP-NOW fields # ESP-NOW fields
espnow_peer_mac: Optional[str] = Field( espnow_peer_mac: str | None = Field(
None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)" None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)"
) )
espnow_channel: Optional[int] = Field( espnow_channel: int | None = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)")
None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)"
)
# Philips Hue fields # Philips Hue fields
hue_username: Optional[str] = Field(None, description="Hue bridge username (from pairing)") hue_username: str | None = Field(None, description="Hue bridge username (from pairing)")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key (hex)") hue_client_key: str | None = Field(None, description="Hue entertainment client key (hex)")
hue_entertainment_group_id: Optional[str] = Field( hue_entertainment_group_id: str | None = Field(
None, description="Hue entertainment group/zone ID" None, description="Hue entertainment group/zone ID"
) )
# Yeelight fields # Yeelight fields
yeelight_min_interval_ms: Optional[int] = Field( yeelight_min_interval_ms: int | None = Field(
None, None,
ge=0, ge=0,
le=10000, le=10000,
description="Yeelight client-side rate limit between commands in ms (default 500)", description="Yeelight client-side rate limit between commands in ms (default 500)",
) )
# WiZ fields # WiZ fields
wiz_min_interval_ms: Optional[int] = Field( wiz_min_interval_ms: int | None = Field(
None, None,
ge=0, ge=0,
le=10000, le=10000,
description="WiZ client-side rate limit between commands in ms (default 50)", description="WiZ client-side rate limit between commands in ms (default 50)",
) )
# LIFX fields # LIFX fields
lifx_min_interval_ms: Optional[int] = Field( lifx_min_interval_ms: int | None = Field(
None, None,
ge=0, ge=0,
le=10000, le=10000,
description="LIFX client-side rate limit between commands in ms (default 50)", description="LIFX client-side rate limit between commands in ms (default 50)",
) )
# Govee fields # Govee fields
govee_min_interval_ms: Optional[int] = Field( govee_min_interval_ms: int | None = Field(
None, None,
ge=0, ge=0,
le=10000, le=10000,
description="Govee client-side rate limit between commands in ms (default 50)", description="Govee client-side rate limit between commands in ms (default 50)",
) )
# OPC fields # OPC fields
opc_channel: Optional[int] = Field( opc_channel: int | None = Field(
None, None,
ge=0, ge=0,
le=255, le=255,
description="OPC channel (0 = broadcast to all channels on the server)", description="OPC channel (0 = broadcast to all channels on the server)",
) )
# Nanoleaf fields # Nanoleaf fields
nanoleaf_token: Optional[str] = Field( nanoleaf_token: str | None = Field(
None, None,
max_length=512, max_length=512,
description="Nanoleaf auth token returned by the pairing handshake", description="Nanoleaf auth token returned by the pairing handshake",
) )
nanoleaf_min_interval_ms: Optional[int] = Field( nanoleaf_min_interval_ms: int | None = Field(
None, None,
ge=0, ge=0,
le=10000, le=10000,
description="Nanoleaf client-side rate limit between commands in ms (default 100)", description="Nanoleaf client-side rate limit between commands in ms (default 100)",
) )
# SPI Direct fields # SPI Direct fields
spi_speed_hz: Optional[int] = Field( spi_speed_hz: int | None = Field(
None, ge=100000, le=4000000, description="SPI clock speed in Hz" None, ge=100000, le=4000000, description="SPI clock speed in Hz"
) )
spi_led_type: Optional[str] = Field( spi_led_type: str | None = Field(
None, description="LED chipset: WS2812, WS2812B, WS2811, SK6812, SK6812_RGBW" None, description="LED chipset: WS2812, WS2812B, WS2811, SK6812, SK6812_RGBW"
) )
# Razer Chroma fields # Razer Chroma fields
chroma_device_type: Optional[str] = Field( chroma_device_type: str | None = Field(
None, None,
description="Chroma peripheral type: keyboard, mouse, mousepad, headset, chromalink, keypad", description="Chroma peripheral type: keyboard, mouse, mousepad, headset, chromalink, keypad",
) )
# SteelSeries GameSense fields # SteelSeries GameSense fields
gamesense_device_type: Optional[str] = Field( gamesense_device_type: str | None = Field(
None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator" None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator"
) )
# BLE controller fields # BLE controller fields
ble_family: Optional[str] = Field( ble_family: str | None = Field(
None, None,
description="BLE protocol family: sp110e, triones, zengge, govee", description="BLE protocol family: sp110e, triones, zengge, govee",
) )
ble_govee_key: Optional[str] = Field( ble_govee_key: str | None = Field(
None, None,
description="Govee AES key (hex) — required for encrypted Govee firmware", description="Govee AES key (hex) — required for encrypted Govee firmware",
) )
default_css_processing_template_id: Optional[str] = Field( default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID" None, description="Default color strip processing template ID"
) )
# Group device fields # Group device fields
group_device_ids: Optional[List[str]] = Field( group_device_ids: List[str] | None = Field(
None, description="Ordered list of child device IDs (for group device type)" None, description="Ordered list of child device IDs (for group device type)"
) )
group_mode: Optional[str] = Field( group_mode: str | None = Field(
None, None,
description="Group mode: sequence (LEDs concatenated) or independent (each child gets full strip resampled)", description="Group mode: sequence (LEDs concatenated) or independent (each child gets full strip resampled)",
) )
# Custom card icon (frontend display only) # Custom card icon (frontend display only)
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library (e.g. 'mouse', 'motherboard'). Empty/null hides the plate.", description="Icon id from the curated icon library (e.g. 'mouse', 'motherboard'). Empty/null hides the plate.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the card's channel accent.", description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the card's channel accent.",
@@ -162,86 +158,80 @@ class DeviceCreate(BaseModel):
class DeviceUpdate(BaseModel): class DeviceUpdate(BaseModel):
"""Request to update device information.""" """Request to update device information."""
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100) name: str | None = Field(None, description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field(None, description="Device URL or serial port") url: str | None = Field(None, description="Device URL or serial port")
enabled: Optional[bool] = Field(None, description="Whether device is enabled") enabled: bool | None = Field(None, description="Whether device is enabled")
led_count: Optional[int] = Field( led_count: int | None = Field(
None, None,
ge=1, ge=1,
le=10000, le=10000,
description="Number of LEDs (for devices with manual_led_count capability)", description="Number of LEDs (for devices with manual_led_count capability)",
) )
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)") baud_rate: int | None = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: Optional[bool] = Field(None, description="Turn off device when server stops") auto_shutdown: bool | None = Field(None, description="Turn off device when server stops")
send_latency_ms: Optional[int] = Field( send_latency_ms: int | None = Field(
None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)" None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)"
) )
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)") rgbw: bool | None = Field(None, description="RGBW mode (mock devices)")
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate") zone_mode: str | None = Field(None, description="OpenRGB zone mode: combined or separate")
tags: Optional[List[str]] = None tags: List[str] | None = None
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn") dmx_protocol: str | None = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field( dmx_start_universe: int | None = Field(None, ge=0, le=32767, description="DMX start universe")
None, ge=0, le=32767, description="DMX start universe" dmx_start_channel: int | None = Field(
)
dmx_start_channel: Optional[int] = Field(
None, ge=1, le=512, description="DMX start channel (1-512)" None, ge=1, le=512, description="DMX start channel (1-512)"
) )
ddp_port: Optional[int] = Field( ddp_port: int | None = Field(
None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)" None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)"
) )
ddp_destination_id: Optional[int] = Field(None, ge=0, le=255, description="DDP destination ID") ddp_destination_id: int | None = Field(None, ge=0, le=255, description="DDP destination ID")
ddp_color_order: Optional[int] = Field(None, ge=0, le=5, description="DDP color order code") ddp_color_order: int | None = Field(None, ge=0, le=5, description="DDP color order code")
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address") espnow_peer_mac: str | None = Field(None, description="ESP-NOW peer MAC address")
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel") espnow_channel: int | None = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel")
hue_username: Optional[str] = Field(None, description="Hue bridge username") hue_username: str | None = Field(None, description="Hue bridge username")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key") hue_client_key: str | None = Field(None, description="Hue entertainment client key")
hue_entertainment_group_id: Optional[str] = Field( hue_entertainment_group_id: str | None = Field(None, description="Hue entertainment group ID")
None, description="Hue entertainment group ID" yeelight_min_interval_ms: int | None = Field(
)
yeelight_min_interval_ms: Optional[int] = Field(
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms" None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
) )
wiz_min_interval_ms: Optional[int] = Field( wiz_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="WiZ client-side rate limit in ms" None, ge=0, le=10000, description="WiZ client-side rate limit in ms"
) )
lifx_min_interval_ms: Optional[int] = Field( lifx_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="LIFX client-side rate limit in ms" None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
) )
govee_min_interval_ms: Optional[int] = Field( govee_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Govee client-side rate limit in ms" None, ge=0, le=10000, description="Govee client-side rate limit in ms"
) )
opc_channel: Optional[int] = Field( opc_channel: int | None = Field(None, ge=0, le=255, description="OPC channel (0 = broadcast)")
None, ge=0, le=255, description="OPC channel (0 = broadcast)" nanoleaf_token: str | None = Field(None, max_length=512, description="Nanoleaf auth token")
) nanoleaf_min_interval_ms: int | None = Field(
nanoleaf_token: Optional[str] = Field(None, max_length=512, description="Nanoleaf auth token")
nanoleaf_min_interval_ms: Optional[int] = Field(
None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms" None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms"
) )
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed") spi_speed_hz: int | None = Field(None, ge=100000, le=4000000, description="SPI clock speed")
spi_led_type: Optional[str] = Field(None, description="LED chipset type") spi_led_type: str | None = Field(None, description="LED chipset type")
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type") chroma_device_type: str | None = Field(None, description="Chroma peripheral type")
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type") gamesense_device_type: str | None = Field(None, description="GameSense device type")
ble_family: Optional[str] = Field( ble_family: str | None = Field(
None, description="BLE protocol family: sp110e, triones, zengge, govee" None, description="BLE protocol family: sp110e, triones, zengge, govee"
) )
ble_govee_key: Optional[str] = Field( ble_govee_key: str | None = Field(
None, description="Govee AES key (hex) — required for encrypted Govee firmware" None, description="Govee AES key (hex) — required for encrypted Govee firmware"
) )
default_css_processing_template_id: Optional[str] = Field( default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID" None, description="Default color strip processing template ID"
) )
# Group device fields # Group device fields
group_device_ids: Optional[List[str]] = Field( group_device_ids: List[str] | None = Field(
None, description="Ordered list of child device IDs (for group device type)" None, description="Ordered list of child device IDs (for group device type)"
) )
group_mode: Optional[str] = Field(None, description="Group mode: sequence or independent") group_mode: str | None = Field(None, description="Group mode: sequence or independent")
# Custom card icon # Custom card icon
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.", description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -294,7 +284,7 @@ class Calibration(BaseModel):
description="Calibration mode: simple (4-edge) or advanced (multi-source lines)", description="Calibration mode: simple (4-edge) or advanced (multi-source lines)",
) )
# Advanced mode: ordered list of lines # Advanced mode: ordered list of lines
lines: Optional[List[CalibrationLineSchema]] = Field( lines: List[CalibrationLineSchema] | None = Field(
default=None, description="Line list for advanced mode (ignored in simple mode)" default=None, description="Line list for advanced mode (ignored in simple mode)"
) )
# Simple mode fields # Simple mode fields
@@ -388,7 +378,7 @@ class DeviceResponse(BaseModel):
device_type: str = Field(default="wled", description="LED device type") device_type: str = Field(default="wled", description="LED device type")
led_count: int = Field(description="Total number of LEDs") led_count: int = Field(description="Total number of LEDs")
enabled: bool = Field(description="Whether device is enabled") enabled: bool = Field(description="Whether device is enabled")
baud_rate: Optional[int] = Field(None, description="Serial baud rate") baud_rate: int | None = Field(None, description="Serial baud rate")
auto_shutdown: bool = Field( auto_shutdown: bool = Field(
default=False, description="Restore device to idle state when targets stop" default=False, description="Restore device to idle state when targets stop"
) )
@@ -473,19 +463,19 @@ class DeviceStateResponse(BaseModel):
device_id: str = Field(description="Device ID") device_id: str = Field(description="Device ID")
device_type: str = Field(default="wled", description="LED device type") device_type: str = Field(default="wled", description="LED device type")
device_online: bool = Field(default=False, description="Whether device is reachable") device_online: bool = Field(default=False, description="Whether device is reachable")
device_latency_ms: Optional[float] = Field(None, description="Health check latency in ms") device_latency_ms: float | None = Field(None, description="Health check latency in ms")
device_name: Optional[str] = Field(None, description="Device name reported by firmware") device_name: str | None = Field(None, description="Device name reported by firmware")
device_version: Optional[str] = Field(None, description="Firmware version") device_version: str | None = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device") device_led_count: int | None = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs") device_rgbw: bool | None = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field( device_led_type: str | None = Field(
None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)" None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)"
) )
device_fps: Optional[int] = Field( device_fps: int | None = Field(
None, description="Device-reported FPS (WLED internal refresh rate)" None, description="Device-reported FPS (WLED internal refresh rate)"
) )
device_last_checked: Optional[datetime] = Field(None, description="Last health check time") device_last_checked: datetime | None = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error") device_error: str | None = Field(None, description="Last health check error")
test_mode: bool = Field(default=False, description="Whether calibration test mode is active") test_mode: bool = Field(default=False, description="Whether calibration test mode is active")
test_mode_edges: List[str] = Field( test_mode_edges: List[str] = Field(
default_factory=list, description="Currently lit edges in test mode" default_factory=list, description="Currently lit edges in test mode"
@@ -500,9 +490,9 @@ class DiscoveredDeviceResponse(BaseModel):
device_type: str = Field(default="wled", description="Device type") device_type: str = Field(default="wled", description="Device type")
ip: str = Field(description="IP address") ip: str = Field(description="IP address")
mac: str = Field(default="", description="MAC address") mac: str = Field(default="", description="MAC address")
led_count: Optional[int] = Field(None, description="LED count (if reachable)") led_count: int | None = Field(None, description="LED count (if reachable)")
version: Optional[str] = Field(None, description="Firmware version") version: str | None = Field(None, description="Firmware version")
ble_family: Optional[str] = Field( ble_family: str | None = Field(
None, description="Detected BLE protocol family (sp110e/triones/zengge/govee)" None, description="Detected BLE protocol family (sp110e/triones/zengge/govee)"
) )
already_added: bool = Field( already_added: bool = Field(
+3 -3
View File
@@ -1,6 +1,6 @@
"""Filter-related schemas.""" """Filter-related schemas."""
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -22,10 +22,10 @@ class FilterOptionDefSchema(BaseModel):
min_value: Any = Field(description="Minimum value") min_value: Any = Field(description="Minimum value")
max_value: Any = Field(description="Maximum value") max_value: Any = Field(description="Maximum value")
step: Any = Field(description="Step increment") step: Any = Field(description="Step increment")
choices: Optional[List[Dict[str, str]]] = Field( choices: List[Dict[str, str]] | None = Field(
default=None, description="Available choices for select type" default=None, description="Available choices for select type"
) )
max_length: Optional[int] = Field( max_length: int | None = Field(
default=None, description="Maximum string length for string type" default=None, description="Maximum string length for string type"
) )
@@ -1,11 +1,10 @@
"""Pydantic schemas for game integration API endpoints.""" """Pydantic schemas for game integration API endpoints."""
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# ── Event Mapping ────────────────────────────────────────────────────────── # ── Event Mapping ──────────────────────────────────────────────────────────
@@ -40,14 +39,14 @@ class GameIntegrationCreate(BaseModel):
event_mappings: List[EventMappingSchema] = Field( event_mappings: List[EventMappingSchema] = Field(
default_factory=list, description="Event-to-effect mappings" default_factory=list, description="Event-to-effect mappings"
) )
description: Optional[str] = Field(None, description="Integration description", max_length=500) description: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.", description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -57,21 +56,21 @@ class GameIntegrationCreate(BaseModel):
class GameIntegrationUpdate(BaseModel): class GameIntegrationUpdate(BaseModel):
"""Request to update a game integration config.""" """Request to update a game integration config."""
name: Optional[str] = Field(None, description="Integration name", min_length=1, max_length=100) name: str | None = Field(None, description="Integration name", min_length=1, max_length=100)
adapter_type: Optional[str] = Field(None, description="Adapter type identifier", min_length=1) adapter_type: str | None = Field(None, description="Adapter type identifier", min_length=1)
enabled: Optional[bool] = Field(None, description="Whether integration is active") enabled: bool | None = Field(None, description="Whether integration is active")
adapter_config: Optional[Dict[str, Any]] = Field(None, description="Adapter-specific settings") adapter_config: Dict[str, Any] | None = Field(None, description="Adapter-specific settings")
event_mappings: Optional[List[EventMappingSchema]] = Field( event_mappings: List[EventMappingSchema] | None = Field(
None, description="Event-to-effect mappings" None, description="Event-to-effect mappings"
) )
description: Optional[str] = Field(None, description="Integration description", max_length=500) description: str | None = Field(None, description="Integration description", max_length=500)
tags: Optional[List[str]] = Field(None, description="User-defined tags") tags: List[str] | None = Field(None, description="User-defined tags")
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.", description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -89,14 +88,14 @@ class GameIntegrationResponse(BaseModel):
event_mappings: List[EventMappingSchema] = Field(description="Event-to-effect mappings") event_mappings: List[EventMappingSchema] = Field(description="Event-to-effect mappings")
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="Integration description") description: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library.", description="Icon id from the curated icon library.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon.", description="Optional CSS color override for the icon.",
@@ -158,7 +157,7 @@ class GameIntegrationStatusResponse(BaseModel):
integration_id: str = Field(description="Integration ID") integration_id: str = Field(description="Integration ID")
enabled: bool = Field(description="Whether integration is active") enabled: bool = Field(description="Whether integration is active")
connected: bool = Field(description="Whether adapter is currently receiving data") connected: bool = Field(description="Whether adapter is currently receiving data")
last_event_time: Optional[float] = Field(None, description="Monotonic timestamp of last event") last_event_time: float | None = Field(None, description="Monotonic timestamp of last event")
event_count: int = Field(default=0, description="Total events received") event_count: int = Field(default=0, description="Total events received")
event_counts_by_type: Dict[str, int] = Field( event_counts_by_type: Dict[str, int] = Field(
default_factory=dict, description="Event counts per event type" default_factory=dict, description="Event counts per event type"
+13 -13
View File
@@ -1,7 +1,7 @@
"""Gradient schemas (CRUD).""" """Gradient schemas (CRUD)."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -18,14 +18,14 @@ class GradientCreate(BaseModel):
name: str = Field(description="Gradient name", min_length=1, max_length=100) name: str = Field(description="Gradient name", min_length=1, max_length=100)
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: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -35,16 +35,16 @@ class GradientCreate(BaseModel):
class GradientUpdate(BaseModel): class GradientUpdate(BaseModel):
"""Request to update a gradient.""" """Request to update a gradient."""
name: Optional[str] = Field(None, description="Gradient name", min_length=1, max_length=100) name: str | None = Field(None, description="Gradient name", min_length=1, max_length=100)
stops: Optional[List[GradientStopSchema]] = Field(None, description="Color stops", min_length=2) stops: List[GradientStopSchema] | None = Field(None, description="Color stops", min_length=2)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -58,16 +58,16 @@ class GradientResponse(BaseModel):
name: str = Field(description="Gradient name") name: str = Field(description="Gradient name")
stops: List[GradientStopSchema] = Field(description="Color stops") stops: List[GradientStopSchema] = Field(description="Color stops")
is_builtin: bool = Field(description="Whether this is a built-in gradient") is_builtin: bool = Field(description="Whether this is a built-in gradient")
description: Optional[str] = Field(None, description="Description") description: str | None = 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")
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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,7 +1,7 @@
"""Home Assistant source schemas (CRUD + test + entities).""" """Home Assistant source schemas (CRUD + test + entities)."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -16,14 +16,14 @@ class HomeAssistantSourceCreate(BaseModel):
entity_filters: List[str] = Field( entity_filters: List[str] = Field(
default_factory=list, description="Entity ID filter patterns (e.g. ['sensor.*'])" default_factory=list, description="Entity ID filter patterns (e.g. ['sensor.*'])"
) )
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.", description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -33,19 +33,19 @@ class HomeAssistantSourceCreate(BaseModel):
class HomeAssistantSourceUpdate(BaseModel): class HomeAssistantSourceUpdate(BaseModel):
"""Request to update a Home Assistant source.""" """Request to update a Home Assistant source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
host: Optional[str] = Field(None, description="HA host:port", min_length=1) host: str | None = Field(None, description="HA host:port", min_length=1)
token: Optional[str] = Field(None, description="Long-Lived Access Token", min_length=1) token: str | None = Field(None, description="Long-Lived Access Token", min_length=1)
use_ssl: Optional[bool] = Field(None, description="Use wss://") use_ssl: bool | None = Field(None, description="Use wss://")
entity_filters: Optional[List[str]] = Field(None, description="Entity ID filter patterns") entity_filters: List[str] | None = Field(None, description="Entity ID filter patterns")
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.", description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -62,21 +62,21 @@ class HomeAssistantSourceResponse(BaseModel):
entity_filters: List[str] = Field(default_factory=list, description="Entity filter patterns") entity_filters: List[str] = Field(default_factory=list, description="Entity filter patterns")
connected: bool = Field(default=False, description="Whether the WebSocket connection is active") connected: bool = Field(default=False, description="Whether the WebSocket connection is active")
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: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library.", description="Icon id from the curated icon library.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon.", 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: str | None = Field(
None, None,
description=( description=(
"Long-Lived Access Token. Redacted as '***' unless the request " "Long-Lived Access Token. Redacted as '***' unless the request "
@@ -112,9 +112,9 @@ class HomeAssistantTestResponse(BaseModel):
"""Connection test result.""" """Connection test result."""
success: bool = Field(description="Whether connection and auth succeeded") success: bool = Field(description="Whether connection and auth succeeded")
ha_version: Optional[str] = Field(None, description="Home Assistant version") ha_version: str | None = Field(None, description="Home Assistant version")
entity_count: int = Field(default=0, description="Number of entities found") entity_count: int = Field(default=0, description="Number of entities found")
error: Optional[str] = Field(None, description="Error message if connection failed") error: str | None = Field(None, description="Error message if connection failed")
class HomeAssistantConnectionStatus(BaseModel): class HomeAssistantConnectionStatus(BaseModel):
@@ -2,12 +2,11 @@
import re import re
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Literal, Optional from typing import Any, Dict, List, Literal
from urllib.parse import urlparse from urllib.parse import urlparse
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
# RFC 7230 token chars for header names + reject any control character in values. # RFC 7230 token chars for header names + reject any control character in values.
_HEADER_NAME_RE = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$") _HEADER_NAME_RE = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$")
_HEADER_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]") _HEADER_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
@@ -64,10 +63,10 @@ class HTTPEndpointCreate(BaseModel):
) )
headers: Dict[str, str] = Field(default_factory=dict) headers: Dict[str, str] = Field(default_factory=dict)
timeout_s: float = Field(default=10.0, gt=0) timeout_s: float = Field(default=10.0, gt=0)
description: Optional[str] = Field(None, max_length=500) description: str | None = Field(None, max_length=500)
tags: List[str] = Field(default_factory=list) tags: List[str] = Field(default_factory=list)
icon: Optional[str] = Field(None, max_length=64) icon: str | None = Field(None, max_length=64)
icon_color: Optional[str] = Field(None, max_length=32) icon_color: str | None = Field(None, max_length=32)
@field_validator("headers") @field_validator("headers")
@classmethod @classmethod
@@ -88,16 +87,16 @@ class HTTPEndpointUpdate(BaseModel):
field (or send ``null``) to keep it. field (or send ``null``) to keep it.
""" """
name: Optional[str] = Field(None, min_length=1, max_length=100) name: str | None = Field(None, min_length=1, max_length=100)
url: Optional[str] = Field(None, min_length=1) url: str | None = Field(None, min_length=1)
method: Optional[Literal["GET", "HEAD"]] = None method: Literal["GET", "HEAD"] | None = None
auth_token: Optional[str] = Field(None, description="null = keep existing; '' = clear.") auth_token: str | None = Field(None, description="null = keep existing; '' = clear.")
headers: Optional[Dict[str, str]] = None headers: Dict[str, str] | None = None
timeout_s: Optional[float] = Field(None, gt=0) timeout_s: float | None = Field(None, gt=0)
description: Optional[str] = Field(None, max_length=500) description: str | None = Field(None, max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field(None, max_length=64) icon: str | None = Field(None, max_length=64)
icon_color: Optional[str] = Field(None, max_length=32) icon_color: str | None = Field(None, max_length=32)
@field_validator("headers") @field_validator("headers")
@classmethod @classmethod
@@ -125,10 +124,10 @@ class HTTPEndpointResponse(BaseModel):
auth_token_set: bool = False auth_token_set: bool = False
headers: Dict[str, str] = Field(default_factory=dict) headers: Dict[str, str] = Field(default_factory=dict)
timeout_s: float timeout_s: float
description: Optional[str] = None description: str | None = None
tags: List[str] = Field(default_factory=list) tags: List[str] = Field(default_factory=list)
icon: Optional[str] = Field(None, max_length=64) icon: str | None = Field(None, max_length=64)
icon_color: Optional[str] = Field(None, max_length=32) icon_color: str | None = Field(None, max_length=32)
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -160,7 +159,7 @@ class HTTPTestRequest(BaseModel):
class HTTPTestResponse(BaseModel): class HTTPTestResponse(BaseModel):
success: bool success: bool
status_code: Optional[int] = None status_code: int | None = None
body_preview: Optional[str] = Field(None, description="First 500 chars of the body") body_preview: str | None = Field(None, description="First 500 chars of the body")
body_json: Any = None body_json: Any = None
error: Optional[str] = None error: str | None = None
+19 -19
View File
@@ -1,7 +1,7 @@
"""MQTT source schemas (CRUD + test + status).""" """MQTT source schemas (CRUD + test + status)."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -16,14 +16,14 @@ class MQTTSourceCreate(BaseModel):
password: str = Field(default="", description="Broker password (optional)") password: str = Field(default="", description="Broker password (optional)")
client_id: str = Field(default="ledgrab", description="MQTT client ID") client_id: str = Field(default="ledgrab", description="MQTT client ID")
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: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.", description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -33,21 +33,21 @@ class MQTTSourceCreate(BaseModel):
class MQTTSourceUpdate(BaseModel): class MQTTSourceUpdate(BaseModel):
"""Request to update an MQTT source.""" """Request to update an MQTT source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
broker_host: Optional[str] = Field(None, description="MQTT broker hostname or IP", min_length=1) broker_host: str | None = Field(None, description="MQTT broker hostname or IP", min_length=1)
broker_port: Optional[int] = Field(None, description="MQTT broker port", ge=1, le=65535) broker_port: int | None = Field(None, description="MQTT broker port", ge=1, le=65535)
username: Optional[str] = Field(None, description="Broker username") username: str | None = Field(None, description="Broker username")
password: Optional[str] = Field(None, description="Broker password") password: str | None = Field(None, description="Broker password")
client_id: Optional[str] = Field(None, description="MQTT client ID") client_id: str | None = Field(None, description="MQTT client ID")
base_topic: Optional[str] = Field(None, description="Base topic prefix") base_topic: str | None = Field(None, description="Base topic prefix")
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.", description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -66,14 +66,14 @@ class MQTTSourceResponse(BaseModel):
client_id: str = Field(description="MQTT client ID") client_id: str = Field(description="MQTT client ID")
base_topic: str = Field(description="Base topic prefix") base_topic: str = Field(description="Base topic prefix")
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: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library.", description="Icon id from the curated icon library.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon.", description="Optional CSS color override for the icon.",
@@ -93,7 +93,7 @@ class MQTTTestResponse(BaseModel):
"""Connection test result.""" """Connection test result."""
success: bool = Field(description="Whether broker connection succeeded") success: bool = Field(description="Whether broker connection succeeded")
error: Optional[str] = Field(None, description="Error message if connection failed") error: str | None = Field(None, description="Error message if connection failed")
class MQTTConnectionStatus(BaseModel): class MQTTConnectionStatus(BaseModel):
+118 -138
View File
@@ -1,7 +1,7 @@
"""Output target schemas — discriminated unions per target type.""" """Output target schemas — discriminated unions per target type."""
from datetime import datetime from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal, Optional, Union from typing import Annotated, Any, Dict, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag from pydantic import BaseModel, Discriminator, Field, Tag
@@ -11,7 +11,7 @@ DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
# BindableFloat — accepts plain number OR {value, source_id} dict # BindableFloat — accepts plain number OR {value, source_id} dict
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
BindableFloatInput = Union[float, int, Dict[str, Any]] BindableFloatInput = float | int | Dict[str, Any]
"""API input type: a plain number (static) or {"value": float, "source_id": str}.""" """API input type: a plain number (static) or {"value": float, "source_id": str}."""
@@ -38,7 +38,7 @@ class HALightMappingSchema(BaseModel):
entity_id: str = Field(description="HA light entity ID (e.g. 'light.living_room')") entity_id: str = Field(description="HA light entity ID (e.g. 'light.living_room')")
led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)") led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)")
led_end: int = Field(default=-1, description="End LED index (-1 = last)") led_end: int = Field(default=-1, description="End LED index (-1 = last)")
brightness_scale: Optional[BindableFloatInput] = Field( brightness_scale: BindableFloatInput | None = Field(
default=1.0, description="Brightness multiplier (bindable)" default=1.0, description="Brightness multiplier (bindable)"
) )
@@ -52,7 +52,7 @@ class Z2MLightMappingSchema(BaseModel):
) )
led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)") led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)")
led_end: int = Field(default=-1, description="End LED index (-1 = last)") led_end: int = Field(default=-1, description="End LED index (-1 = last)")
brightness_scale: Optional[BindableFloatInput] = Field( brightness_scale: BindableFloatInput | None = Field(
default=1.0, description="Brightness multiplier (bindable)" default=1.0, description="Brightness multiplier (bindable)"
) )
@@ -67,7 +67,7 @@ class _OutputTargetResponseBase(BaseModel):
id: str = Field(description="Target ID") id: str = Field(description="Target ID")
name: str = Field(description="Target name") name: str = Field(description="Target name")
description: Optional[str] = Field(None, description="Description") description: str | None = 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: str = Field(default="", description="Custom icon id from the curated icon library") icon: str = Field(default="", description="Custom icon id from the curated icon library")
icon_color: str = Field(default="", description="Optional CSS color override for the icon") icon_color: str = Field(default="", description="Optional CSS color override for the icon")
@@ -79,13 +79,13 @@ class LedOutputTargetResponse(_OutputTargetResponseBase):
target_type: Literal["led"] = "led" target_type: Literal["led"] = "led"
device_id: str = Field(default="", description="LED device ID") device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID") color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)") brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable)") fps: BindableFloatInput | None = Field(None, description="Target send FPS (bindable)")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)") keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field( state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)" default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
) )
min_brightness_threshold: Optional[BindableFloatInput] = Field( min_brightness_threshold: BindableFloatInput | None = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)" default=0, description="Min brightness threshold (bindable, 0=disabled)"
) )
adaptive_fps: bool = Field( adaptive_fps: bool = Field(
@@ -110,20 +110,20 @@ class HALightOutputTargetResponse(_OutputTargetResponseBase):
description="Colour value source ID (used when source_kind='color_vs'); " description="Colour value source ID (used when source_kind='color_vs'); "
"must reference a value source whose return_type='color'.", "must reference a value source whose return_type='color'.",
) )
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)") brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field( ha_light_mappings: List[HALightMappingSchema] | None = Field(
None, description="LED-to-light mappings" None, description="LED-to-light mappings"
) )
update_rate: Optional[BindableFloatInput] = Field( update_rate: BindableFloatInput | None = Field(
None, description="Service call rate Hz (bindable)" None, description="Service call rate Hz (bindable)"
) )
transition: Optional[BindableFloatInput] = Field( transition: BindableFloatInput | None = Field(
None, description="HA transition seconds (bindable)" None, description="HA transition seconds (bindable)"
) )
color_tolerance: Optional[BindableFloatInput] = Field( color_tolerance: BindableFloatInput | None = Field(
None, description="RGB delta tolerance (bindable)" None, description="RGB delta tolerance (bindable)"
) )
min_brightness_threshold: Optional[BindableFloatInput] = Field( min_brightness_threshold: BindableFloatInput | None = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)" default=0, description="Min brightness threshold (bindable, 0=disabled)"
) )
stop_action: Literal["none", "turn_off", "restore"] = Field( stop_action: Literal["none", "turn_off", "restore"] = Field(
@@ -151,24 +151,24 @@ class Z2MLightOutputTargetResponse(_OutputTargetResponseBase):
default="", default="",
description="Colour value source ID (used when source_kind='color_vs').", description="Colour value source ID (used when source_kind='color_vs').",
) )
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)") brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field( z2m_light_mappings: List[Z2MLightMappingSchema] | None = Field(
None, description="LED-to-bulb mappings (by Z2M friendly_name)" None, description="LED-to-bulb mappings (by Z2M friendly_name)"
) )
base_topic: str = Field( base_topic: str = Field(
default="zigbee2mqtt", default="zigbee2mqtt",
description="Z2M MQTT base topic prefix (override if your Z2M instance is non-default).", description="Z2M MQTT base topic prefix (override if your Z2M instance is non-default).",
) )
update_rate: Optional[BindableFloatInput] = Field( update_rate: BindableFloatInput | None = Field(
None, description="Publish rate Hz (bindable; 0.5-10)" None, description="Publish rate Hz (bindable; 0.5-10)"
) )
transition: Optional[BindableFloatInput] = Field( transition: BindableFloatInput | None = Field(
None, description="Z2M transition seconds (bindable)" None, description="Z2M transition seconds (bindable)"
) )
color_tolerance: Optional[BindableFloatInput] = Field( color_tolerance: BindableFloatInput | None = Field(
None, description="RGB delta tolerance (bindable)" None, description="RGB delta tolerance (bindable)"
) )
min_brightness_threshold: Optional[BindableFloatInput] = Field( min_brightness_threshold: BindableFloatInput | None = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)" default=0, description="Min brightness threshold (bindable, 0=disabled)"
) )
stop_action: Literal["none", "turn_off"] = Field( stop_action: Literal["none", "turn_off"] = Field(
@@ -179,11 +179,9 @@ class Z2MLightOutputTargetResponse(_OutputTargetResponseBase):
OutputTargetResponse = Annotated[ OutputTargetResponse = Annotated[
Union[ Annotated[LedOutputTargetResponse, Tag("led")]
Annotated[LedOutputTargetResponse, Tag("led")], | Annotated[HALightOutputTargetResponse, Tag("ha_light")]
Annotated[HALightOutputTargetResponse, Tag("ha_light")], | Annotated[Z2MLightOutputTargetResponse, Tag("z2m_light")],
Annotated[Z2MLightOutputTargetResponse, Tag("z2m_light")],
],
Discriminator("target_type"), Discriminator("target_type"),
] ]
@@ -196,12 +194,12 @@ class _OutputTargetCreateBase(BaseModel):
"""Shared fields for all output target create requests.""" """Shared fields for all output target create requests."""
name: str = Field(description="Target name", min_length=1, max_length=100) name: str = Field(description="Target name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = 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( icon: str | None = Field(
None, max_length=64, description="Custom icon id from the curated icon library" None, max_length=64, description="Custom icon id from the curated icon library"
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, max_length=32, description="Optional CSS color override for the icon" None, max_length=32, description="Optional CSS color override for the icon"
) )
@@ -210,10 +208,8 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
target_type: Literal["led"] = "led" target_type: Literal["led"] = "led"
device_id: str = Field(default="", description="LED device ID") device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID") color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field( brightness: BindableFloatInput | None = Field(default=1.0, description="Brightness (bindable)")
default=1.0, description="Brightness (bindable)" fps: BindableFloatInput | None = Field(
)
fps: Optional[BindableFloatInput] = Field(
default=30, description="Target send FPS (bindable, 1-90)" default=30, description="Target send FPS (bindable, 1-90)"
) )
keepalive_interval: float = Field( keepalive_interval: float = Field(
@@ -228,7 +224,7 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
ge=5, ge=5,
le=600, le=600,
) )
min_brightness_threshold: Optional[BindableFloatInput] = Field( min_brightness_threshold: BindableFloatInput | None = Field(
default=0, default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off", description="Min brightness threshold (bindable, 0=disabled); below this -> off",
) )
@@ -257,22 +253,20 @@ class HALightOutputTargetCreate(_OutputTargetCreateBase):
default="", default="",
description="Colour value source ID (used when source_kind='color_vs').", description="Colour value source ID (used when source_kind='color_vs').",
) )
brightness: Optional[BindableFloatInput] = Field( brightness: BindableFloatInput | None = Field(default=1.0, description="Brightness (bindable)")
default=1.0, description="Brightness (bindable)" ha_light_mappings: List[HALightMappingSchema] | None = Field(
)
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings" None, description="LED-to-light mappings"
) )
update_rate: Optional[BindableFloatInput] = Field( update_rate: BindableFloatInput | None = Field(
default=2.0, description="Service call rate in Hz (bindable)" default=2.0, description="Service call rate in Hz (bindable)"
) )
transition: Optional[BindableFloatInput] = Field( transition: BindableFloatInput | None = Field(
default=0.5, description="HA transition seconds (bindable)" default=0.5, description="HA transition seconds (bindable)"
) )
color_tolerance: Optional[BindableFloatInput] = Field( color_tolerance: BindableFloatInput | None = Field(
default=5, description="RGB delta tolerance (bindable)" default=5, description="RGB delta tolerance (bindable)"
) )
min_brightness_threshold: Optional[BindableFloatInput] = Field( min_brightness_threshold: BindableFloatInput | None = Field(
default=0, default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off", description="Min brightness threshold (bindable, 0=disabled); below this -> off",
) )
@@ -299,10 +293,8 @@ class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
default="", default="",
description="Colour value source ID (used when source_kind='color_vs').", description="Colour value source ID (used when source_kind='color_vs').",
) )
brightness: Optional[BindableFloatInput] = Field( brightness: BindableFloatInput | None = Field(default=1.0, description="Brightness (bindable)")
default=1.0, description="Brightness (bindable)" z2m_light_mappings: List[Z2MLightMappingSchema] | None = Field(
)
z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field(
None, description="LED-to-bulb mappings (by Z2M friendly_name)" None, description="LED-to-bulb mappings (by Z2M friendly_name)"
) )
base_topic: str = Field( base_topic: str = Field(
@@ -310,16 +302,16 @@ class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
max_length=128, max_length=128,
description="Z2M MQTT base topic prefix.", description="Z2M MQTT base topic prefix.",
) )
update_rate: Optional[BindableFloatInput] = Field( update_rate: BindableFloatInput | None = Field(
default=5.0, description="Publish rate in Hz (bindable; 0.5-10)" default=5.0, description="Publish rate in Hz (bindable; 0.5-10)"
) )
transition: Optional[BindableFloatInput] = Field( transition: BindableFloatInput | None = Field(
default=0.3, description="Z2M transition seconds (bindable)" default=0.3, description="Z2M transition seconds (bindable)"
) )
color_tolerance: Optional[BindableFloatInput] = Field( color_tolerance: BindableFloatInput | None = Field(
default=5, description="RGB delta tolerance (bindable)" default=5, description="RGB delta tolerance (bindable)"
) )
min_brightness_threshold: Optional[BindableFloatInput] = Field( min_brightness_threshold: BindableFloatInput | None = Field(
default=0, default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off", description="Min brightness threshold (bindable, 0=disabled); below this -> off",
) )
@@ -330,11 +322,9 @@ class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
OutputTargetCreate = Annotated[ OutputTargetCreate = Annotated[
Union[ Annotated[LedOutputTargetCreate, Tag("led")]
Annotated[LedOutputTargetCreate, Tag("led")], | Annotated[HALightOutputTargetCreate, Tag("ha_light")]
Annotated[HALightOutputTargetCreate, Tag("ha_light")], | Annotated[Z2MLightOutputTargetCreate, Tag("z2m_light")],
Annotated[Z2MLightOutputTargetCreate, Tag("z2m_light")],
],
Discriminator("target_type"), Discriminator("target_type"),
] ]
@@ -346,15 +336,15 @@ OutputTargetCreate = Annotated[
class _OutputTargetUpdateBase(BaseModel): class _OutputTargetUpdateBase(BaseModel):
"""Shared fields for all output target update requests.""" """Shared fields for all output target update requests."""
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100) name: str | None = Field(None, description="Target name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Custom icon id; pass empty string to clear and inherit from device.", description="Custom icon id; pass empty string to clear and inherit from device.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon; empty string clears.", description="Optional CSS color override for the icon; empty string clears.",
@@ -363,103 +353,99 @@ class _OutputTargetUpdateBase(BaseModel):
class LedOutputTargetUpdate(_OutputTargetUpdateBase): class LedOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["led"] = "led" target_type: Literal["led"] = "led"
device_id: Optional[str] = Field(None, description="LED device ID") device_id: str | None = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") color_strip_source_id: str | None = Field(None, description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)") brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable, 1-90)") fps: BindableFloatInput | None = Field(None, description="Target send FPS (bindable, 1-90)")
keepalive_interval: Optional[float] = Field( keepalive_interval: float | None = Field(
None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0 None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0
) )
state_check_interval: Optional[int] = Field( state_check_interval: int | None = Field(
None, description="Health check interval (5-600s)", ge=5, le=600 None, description="Health check interval (5-600s)", ge=5, le=600
) )
min_brightness_threshold: Optional[BindableFloatInput] = Field( min_brightness_threshold: BindableFloatInput | None = Field(
None, description="Min brightness threshold (bindable, 0=disabled)" None, description="Min brightness threshold (bindable, 0=disabled)"
) )
adaptive_fps: Optional[bool] = Field( adaptive_fps: bool | None = Field(
None, description="Auto-reduce FPS when device is unresponsive" None, description="Auto-reduce FPS when device is unresponsive"
) )
protocol: Optional[str] = Field( protocol: str | None = Field(
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)" None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
) )
class HALightOutputTargetUpdate(_OutputTargetUpdateBase): class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["ha_light"] = "ha_light" target_type: Literal["ha_light"] = "ha_light"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID") ha_source_id: str | None = Field(None, description="Home Assistant source ID")
source_kind: Optional[Literal["css", "color_vs"]] = Field( source_kind: Literal["css", "color_vs"] | None = Field(
None, None,
description="Colour source kind: 'css' or 'color_vs'.", description="Colour source kind: 'css' or 'color_vs'.",
) )
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") color_strip_source_id: str | None = Field(None, description="Color strip source ID")
color_value_source_id: Optional[str] = Field( color_value_source_id: str | None = Field(
None, None,
description="Colour value source ID (used when source_kind='color_vs').", description="Colour value source ID (used when source_kind='color_vs').",
) )
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)") brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field( ha_light_mappings: List[HALightMappingSchema] | None = Field(
None, description="LED-to-light mappings" None, description="LED-to-light mappings"
) )
update_rate: Optional[BindableFloatInput] = Field( update_rate: BindableFloatInput | None = Field(
None, description="Service call rate Hz (bindable)" None, description="Service call rate Hz (bindable)"
) )
transition: Optional[BindableFloatInput] = Field( transition: BindableFloatInput | None = Field(
None, description="HA transition seconds (bindable)" None, description="HA transition seconds (bindable)"
) )
color_tolerance: Optional[BindableFloatInput] = Field( color_tolerance: BindableFloatInput | None = Field(
None, description="RGB delta tolerance (bindable)" None, description="RGB delta tolerance (bindable)"
) )
min_brightness_threshold: Optional[BindableFloatInput] = Field( min_brightness_threshold: BindableFloatInput | None = Field(
None, description="Min brightness threshold (bindable, 0=disabled)" None, description="Min brightness threshold (bindable, 0=disabled)"
) )
stop_action: Optional[Literal["none", "turn_off", "restore"]] = Field( stop_action: Literal["none", "turn_off", "restore"] | None = Field(
None, description="Finalization on stop: 'none', 'turn_off', or 'restore'." None, description="Finalization on stop: 'none', 'turn_off', or 'restore'."
) )
class Z2MLightOutputTargetUpdate(_OutputTargetUpdateBase): class Z2MLightOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["z2m_light"] = "z2m_light" target_type: Literal["z2m_light"] = "z2m_light"
mqtt_source_id: Optional[str] = Field( mqtt_source_id: str | None = Field(
None, None,
description="MQTT source (broker) id. Empty string clears the binding.", description="MQTT source (broker) id. Empty string clears the binding.",
) )
source_kind: Optional[Literal["css", "color_vs"]] = Field( source_kind: Literal["css", "color_vs"] | None = Field(
None, description="Colour source kind: 'css' or 'color_vs'." None, description="Colour source kind: 'css' or 'color_vs'."
) )
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") color_strip_source_id: str | None = Field(None, description="Color strip source ID")
color_value_source_id: Optional[str] = Field( color_value_source_id: str | None = Field(
None, description="Colour value source ID (used when source_kind='color_vs')." None, description="Colour value source ID (used when source_kind='color_vs')."
) )
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)") brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field( z2m_light_mappings: List[Z2MLightMappingSchema] | None = Field(
None, description="LED-to-bulb mappings (by Z2M friendly_name)" None, description="LED-to-bulb mappings (by Z2M friendly_name)"
) )
base_topic: Optional[str] = Field( base_topic: str | None = Field(None, max_length=128, description="Z2M MQTT base topic prefix.")
None, max_length=128, description="Z2M MQTT base topic prefix." update_rate: BindableFloatInput | None = Field(
)
update_rate: Optional[BindableFloatInput] = Field(
None, description="Publish rate Hz (bindable; 0.5-10)" None, description="Publish rate Hz (bindable; 0.5-10)"
) )
transition: Optional[BindableFloatInput] = Field( transition: BindableFloatInput | None = Field(
None, description="Z2M transition seconds (bindable)" None, description="Z2M transition seconds (bindable)"
) )
color_tolerance: Optional[BindableFloatInput] = Field( color_tolerance: BindableFloatInput | None = Field(
None, description="RGB delta tolerance (bindable)" None, description="RGB delta tolerance (bindable)"
) )
min_brightness_threshold: Optional[BindableFloatInput] = Field( min_brightness_threshold: BindableFloatInput | None = Field(
None, description="Min brightness threshold (bindable, 0=disabled)" None, description="Min brightness threshold (bindable, 0=disabled)"
) )
stop_action: Optional[Literal["none", "turn_off"]] = Field( stop_action: Literal["none", "turn_off"] | None = Field(
None, description="Finalization on stop: 'none' or 'turn_off'." None, description="Finalization on stop: 'none' or 'turn_off'."
) )
OutputTargetUpdate = Annotated[ OutputTargetUpdate = Annotated[
Union[ Annotated[LedOutputTargetUpdate, Tag("led")]
Annotated[LedOutputTargetUpdate, Tag("led")], | Annotated[HALightOutputTargetUpdate, Tag("ha_light")]
Annotated[HALightOutputTargetUpdate, Tag("ha_light")], | Annotated[Z2MLightOutputTargetUpdate, Tag("z2m_light")],
Annotated[Z2MLightOutputTargetUpdate, Tag("z2m_light")],
],
Discriminator("target_type"), Discriminator("target_type"),
] ]
@@ -479,75 +465,69 @@ class TargetProcessingState(BaseModel):
"""Processing state for an output target.""" """Processing state for an output target."""
target_id: str = Field(description="Target ID") target_id: str = Field(description="Target ID")
device_id: Optional[str] = Field(None, description="Device ID") device_id: str | None = Field(None, description="Device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID") color_strip_source_id: str = Field(default="", description="Color strip source ID")
processing: bool = Field(description="Whether processing is active") processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved") fps_actual: float | None = Field(None, description="Actual FPS achieved")
fps_potential: Optional[float] = Field( fps_potential: float | None = Field(
None, description="Potential FPS (processing speed without throttle)" None, description="Potential FPS (processing speed without throttle)"
) )
fps_target: Optional[int] = Field(None, description="Target FPS") fps_target: int | None = Field(None, description="Target FPS")
fps_capture: Optional[int] = Field( fps_capture: int | None = Field(
None, description="Configured capture-side FPS for the underlying color strip stream" None, description="Configured capture-side FPS for the underlying color strip stream"
) )
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)") frames_skipped: int | None = Field(None, description="Frames skipped (no screen change)")
frames_keepalive: Optional[int] = Field( frames_keepalive: int | None = Field(None, description="Keepalive frames sent during standby")
None, description="Keepalive frames sent during standby" fps_current: int | None = Field(None, description="Frames sent in the last second")
) timing_send_ms: float | None = Field(None, description="DDP send time (ms)")
fps_current: Optional[int] = Field(None, description="Frames sent in the last second") timing_extract_ms: float | None = Field(None, description="Border pixel extraction time (ms)")
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)") timing_map_leds_ms: float | None = Field(None, description="LED color mapping time (ms)")
timing_extract_ms: Optional[float] = Field( timing_smooth_ms: float | None = Field(None, description="Temporal smoothing time (ms)")
None, description="Border pixel extraction time (ms)" timing_total_ms: float | None = Field(None, description="Total processing time per frame (ms)")
) timing_audio_read_ms: float | None = Field(None, description="Audio device read time (ms)")
timing_map_leds_ms: Optional[float] = Field(None, description="LED color mapping time (ms)") timing_audio_fft_ms: float | None = Field(None, description="Audio FFT analysis time (ms)")
timing_smooth_ms: Optional[float] = Field(None, description="Temporal smoothing time (ms)") timing_audio_render_ms: float | None = Field(
timing_total_ms: Optional[float] = Field(
None, description="Total processing time per frame (ms)"
)
timing_audio_read_ms: Optional[float] = Field(None, description="Audio device read time (ms)")
timing_audio_fft_ms: Optional[float] = Field(None, description="Audio FFT analysis time (ms)")
timing_audio_render_ms: Optional[float] = Field(
None, description="Audio visualization render time (ms)" None, description="Audio visualization render time (ms)"
) )
display_index: Optional[int] = Field(None, description="Current display index") display_index: int | None = Field(None, description="Current display index")
overlay_active: bool = Field( overlay_active: bool = Field(
default=False, description="Whether visualization overlay is active" default=False, description="Whether visualization overlay is active"
) )
last_update: Optional[datetime] = Field(None, description="Last successful update") last_update: datetime | None = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors") errors: List[str] = Field(default_factory=list, description="Recent errors")
device_online: bool = Field(default=False, description="Whether device is reachable") device_online: bool = Field(default=False, description="Whether device is reachable")
device_latency_ms: Optional[float] = Field(None, description="Health check latency in ms") device_latency_ms: float | None = Field(None, description="Health check latency in ms")
device_name: Optional[str] = Field(None, description="Device name reported by firmware") device_name: str | None = Field(None, description="Device name reported by firmware")
device_version: Optional[str] = Field(None, description="Firmware version") device_version: str | None = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device") device_led_count: int | None = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs") device_rgbw: bool | None = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field( device_led_type: str | None = Field(
None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)" None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)"
) )
device_fps: Optional[int] = Field( device_fps: int | None = Field(
None, description="Device-reported FPS (WLED internal refresh rate)" None, description="Device-reported FPS (WLED internal refresh rate)"
) )
device_last_checked: Optional[datetime] = Field(None, description="Last health check time") device_last_checked: datetime | None = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error") device_error: str | None = Field(None, description="Last health check error")
device_streaming_reachable: Optional[bool] = Field( device_streaming_reachable: bool | None = Field(
None, description="Device reachable during streaming (HTTP probe)" None, description="Device reachable during streaming (HTTP probe)"
) )
fps_effective: Optional[int] = Field(None, description="Effective FPS after adaptive reduction") fps_effective: int | None = Field(None, description="Effective FPS after adaptive reduction")
class TargetMetricsResponse(BaseModel): class TargetMetricsResponse(BaseModel):
"""Target metrics response.""" """Target metrics response."""
target_id: str = Field(description="Target ID") target_id: str = Field(description="Target ID")
device_id: Optional[str] = Field(None, description="Device ID") device_id: str | None = Field(None, description="Device ID")
processing: bool = Field(description="Whether processing is active") processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS") fps_actual: float | None = Field(None, description="Actual FPS")
fps_target: Optional[int] = Field(None, description="Target FPS") fps_target: int | None = Field(None, description="Target FPS")
uptime_seconds: float = Field(description="Processing uptime in seconds") uptime_seconds: float = Field(description="Processing uptime in seconds")
frames_processed: int = Field(description="Total frames processed") frames_processed: int = Field(description="Total frames processed")
errors_count: int = Field(description="Total error count") errors_count: int = Field(description="Total error count")
last_error: Optional[str] = Field(None, description="Last error message") last_error: str | None = Field(None, description="Last error message")
last_update: Optional[datetime] = Field(None, description="Last update timestamp") last_update: datetime | None = Field(None, description="Last update timestamp")
class BulkTargetRequest(BaseModel): class BulkTargetRequest(BaseModel):
@@ -1,7 +1,7 @@
"""Pydantic schemas for pattern template API.""" """Pydantic schemas for pattern template API."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class PatternTemplateCreate(BaseModel):
rectangles: List[KeyColorRectangleSchema] = Field( rectangles: List[KeyColorRectangleSchema] = Field(
default_factory=list, description="List of named rectangles" default_factory=list, description="List of named rectangles"
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class PatternTemplateCreate(BaseModel):
class PatternTemplateUpdate(BaseModel): class PatternTemplateUpdate(BaseModel):
"""Request to update a pattern template.""" """Request to update a pattern template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
rectangles: Optional[List[KeyColorRectangleSchema]] = Field( rectangles: List[KeyColorRectangleSchema] | None = Field(
None, description="List of named rectangles" None, description="List of named rectangles"
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -59,13 +59,13 @@ class PatternTemplateResponse(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")
description: Optional[str] = Field(None, description="Template description") description: str | None = Field(None, description="Template description")
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,7 +1,7 @@
"""Picture source schemas — discriminated unions per stream type.""" """Picture source schemas — discriminated unions per stream type."""
from datetime import datetime from datetime import datetime
from typing import Annotated, List, Literal, Optional, Union from typing import Annotated, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag from pydantic import BaseModel, Discriminator, Field, Tag
@@ -15,16 +15,16 @@ class _PictureSourceResponseBase(BaseModel):
id: str = Field(description="Stream ID") id: str = Field(description="Stream ID")
name: str = Field(description="Stream name") name: str = Field(description="Stream name")
description: Optional[str] = Field(None, description="Stream description") description: str | None = Field(None, description="Stream description")
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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -46,28 +46,26 @@ class ProcessedPictureSourceResponse(_PictureSourceResponseBase):
class StaticImagePictureSourceResponse(_PictureSourceResponseBase): class StaticImagePictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["static_image"] = "static_image" stream_type: Literal["static_image"] = "static_image"
image_asset_id: Optional[str] = Field(None, description="Image asset ID") image_asset_id: str | None = Field(None, description="Image asset ID")
class VideoPictureSourceResponse(_PictureSourceResponseBase): class VideoPictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["video"] = "video" stream_type: Literal["video"] = "video"
video_asset_id: Optional[str] = Field(None, description="Video asset ID") video_asset_id: str | None = Field(None, description="Video asset ID")
loop: bool = Field(True, description="Loop video playback") loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, description="Playback speed multiplier") playback_speed: float = Field(1.0, description="Playback speed multiplier")
start_time: Optional[float] = Field(None, description="Trim start time in seconds") start_time: float | None = Field(None, description="Trim start time in seconds")
end_time: Optional[float] = Field(None, description="Trim end time in seconds") end_time: float | None = Field(None, description="Trim end time in seconds")
resolution_limit: Optional[int] = Field(None, description="Max width for decode") resolution_limit: int | None = Field(None, description="Max width for decode")
clock_id: Optional[str] = Field(None, description="Sync clock ID") clock_id: str | None = Field(None, description="Sync clock ID")
target_fps: int = Field(30, description="Target FPS") target_fps: int = Field(30, description="Target FPS")
PictureSourceResponse = Annotated[ PictureSourceResponse = Annotated[
Union[ Annotated[RawPictureSourceResponse, Tag("raw")]
Annotated[RawPictureSourceResponse, Tag("raw")], | Annotated[ProcessedPictureSourceResponse, Tag("processed")]
Annotated[ProcessedPictureSourceResponse, Tag("processed")], | Annotated[StaticImagePictureSourceResponse, Tag("static_image")]
Annotated[StaticImagePictureSourceResponse, Tag("static_image")], | Annotated[VideoPictureSourceResponse, Tag("video")],
Annotated[VideoPictureSourceResponse, Tag("video")],
],
Discriminator("stream_type"), Discriminator("stream_type"),
] ]
@@ -80,14 +78,14 @@ class _PictureSourceCreateBase(BaseModel):
"""Shared fields for all picture source create requests.""" """Shared fields for all picture source create requests."""
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: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -117,22 +115,20 @@ class VideoPictureSourceCreate(_PictureSourceCreateBase):
video_asset_id: str = Field(description="Video asset ID") video_asset_id: str = Field(description="Video asset ID")
loop: bool = Field(True, description="Loop video playback") loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0) playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0) start_time: float | None = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0) end_time: float | None = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field( resolution_limit: int | None = Field(
None, description="Max width in pixels for decode downscale", ge=64, le=7680 None, description="Max width in pixels for decode downscale", ge=64, le=7680
) )
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing") clock_id: str | None = Field(None, description="Sync clock ID for frame-accurate timing")
target_fps: int = Field(30, description="Target FPS", ge=1, le=90) target_fps: int = Field(30, description="Target FPS", ge=1, le=90)
PictureSourceCreate = Annotated[ PictureSourceCreate = Annotated[
Union[ Annotated[RawPictureSourceCreate, Tag("raw")]
Annotated[RawPictureSourceCreate, Tag("raw")], | Annotated[ProcessedPictureSourceCreate, Tag("processed")]
Annotated[ProcessedPictureSourceCreate, Tag("processed")], | Annotated[StaticImagePictureSourceCreate, Tag("static_image")]
Annotated[StaticImagePictureSourceCreate, Tag("static_image")], | Annotated[VideoPictureSourceCreate, Tag("video")],
Annotated[VideoPictureSourceCreate, Tag("video")],
],
Discriminator("stream_type"), Discriminator("stream_type"),
] ]
@@ -144,15 +140,15 @@ PictureSourceCreate = Annotated[
class _PictureSourceUpdateBase(BaseModel): class _PictureSourceUpdateBase(BaseModel):
"""Shared fields for all picture source update requests.""" """Shared fields for all picture source update requests."""
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100) name: str | None = Field(None, description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500) description: str | None = Field(None, description="Stream description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -161,47 +157,43 @@ class _PictureSourceUpdateBase(BaseModel):
class RawPictureSourceUpdate(_PictureSourceUpdateBase): class RawPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["raw"] = "raw" stream_type: Literal["raw"] = "raw"
display_index: Optional[int] = Field(None, description="Display index", ge=0) display_index: int | None = Field(None, description="Display index", ge=0)
capture_template_id: Optional[str] = Field(None, description="Capture template ID") capture_template_id: str | None = Field(None, description="Capture template ID")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90) target_fps: int | None = Field(None, description="Target FPS", ge=1, le=90)
class ProcessedPictureSourceUpdate(_PictureSourceUpdateBase): class ProcessedPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["processed"] = "processed" stream_type: Literal["processed"] = "processed"
source_stream_id: Optional[str] = Field(None, description="Source stream ID") source_stream_id: str | None = Field(None, description="Source stream ID")
postprocessing_template_id: Optional[str] = Field( postprocessing_template_id: str | None = Field(None, description="Postprocessing template ID")
None, description="Postprocessing template ID"
)
class StaticImagePictureSourceUpdate(_PictureSourceUpdateBase): class StaticImagePictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["static_image"] = "static_image" stream_type: Literal["static_image"] = "static_image"
image_asset_id: Optional[str] = Field(None, description="Image asset ID") image_asset_id: str | None = Field(None, description="Image asset ID")
class VideoPictureSourceUpdate(_PictureSourceUpdateBase): class VideoPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["video"] = "video" stream_type: Literal["video"] = "video"
video_asset_id: Optional[str] = Field(None, description="Video asset ID") video_asset_id: str | None = Field(None, description="Video asset ID")
loop: Optional[bool] = Field(None, description="Loop video playback") loop: bool | None = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field( playback_speed: float | None = Field(
None, description="Playback speed multiplier", ge=0.1, le=10.0 None, description="Playback speed multiplier", ge=0.1, le=10.0
) )
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0) start_time: float | None = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0) end_time: float | None = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field( resolution_limit: int | None = Field(
None, description="Max width in pixels for decode downscale", ge=64, le=7680 None, description="Max width in pixels for decode downscale", ge=64, le=7680
) )
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing") clock_id: str | None = Field(None, description="Sync clock ID for frame-accurate timing")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90) target_fps: int | None = Field(None, description="Target FPS", ge=1, le=90)
PictureSourceUpdate = Annotated[ PictureSourceUpdate = Annotated[
Union[ Annotated[RawPictureSourceUpdate, Tag("raw")]
Annotated[RawPictureSourceUpdate, Tag("raw")], | Annotated[ProcessedPictureSourceUpdate, Tag("processed")]
Annotated[ProcessedPictureSourceUpdate, Tag("processed")], | Annotated[StaticImagePictureSourceUpdate, Tag("static_image")]
Annotated[StaticImagePictureSourceUpdate, Tag("static_image")], | Annotated[VideoPictureSourceUpdate, Tag("video")],
Annotated[VideoPictureSourceUpdate, Tag("video")],
],
Discriminator("stream_type"), Discriminator("stream_type"),
] ]
@@ -246,7 +238,7 @@ class ImageValidateResponse(BaseModel):
"""Response from image validation.""" """Response from image validation."""
valid: bool = Field(description="Whether the image source is accessible and valid") valid: bool = Field(description="Whether the image source is accessible and valid")
width: Optional[int] = Field(None, description="Image width in pixels") width: int | None = Field(None, description="Image width in pixels")
height: Optional[int] = Field(None, description="Image height in pixels") height: int | None = Field(None, description="Image height in pixels")
preview: Optional[str] = Field(None, description="Base64-encoded JPEG thumbnail") preview: str | None = Field(None, description="Base64-encoded JPEG thumbnail")
error: Optional[str] = Field(None, description="Error message if invalid") error: str | None = Field(None, description="Error message if invalid")
@@ -1,7 +1,7 @@
"""Postprocessing template schemas.""" """Postprocessing template schemas."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class PostprocessingTemplateCreate(BaseModel):
filters: List[FilterInstanceSchema] = Field( filters: List[FilterInstanceSchema] = Field(
default_factory=list, description="Ordered list of filter instances" default_factory=list, description="Ordered list of filter instances"
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class PostprocessingTemplateCreate(BaseModel):
class PostprocessingTemplateUpdate(BaseModel): class PostprocessingTemplateUpdate(BaseModel):
"""Request to update a postprocessing template.""" """Request to update a postprocessing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field( filters: List[FilterInstanceSchema] | None = Field(
None, description="Ordered list of filter instances" None, description="Ordered list of filter instances"
) )
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -59,13 +59,13 @@ class PostprocessingTemplateResponse(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")
description: Optional[str] = Field(None, description="Template description") description: str | None = Field(None, description="Template description")
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
+13 -15
View File
@@ -1,7 +1,7 @@
"""Scene preset API schemas.""" """Scene preset API schemas."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -19,16 +19,14 @@ class ScenePresetCreate(BaseModel):
name: str = Field(description="Preset name", min_length=1, max_length=100) name: str = Field(description="Preset name", min_length=1, max_length=100)
description: str = Field(default="", max_length=500) description: str = Field(default="", max_length=500)
target_ids: Optional[List[str]] = Field( target_ids: List[str] | None = Field(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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -38,20 +36,20 @@ class ScenePresetCreate(BaseModel):
class ScenePresetUpdate(BaseModel): class ScenePresetUpdate(BaseModel):
"""Update scene preset metadata and optionally change which targets are included.""" """Update scene preset metadata and optionally change which targets are included."""
name: Optional[str] = Field(None, min_length=1, max_length=100) name: str | None = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500) description: str | None = Field(None, max_length=500)
order: Optional[int] = None order: int | None = None
target_ids: Optional[List[str]] = Field( target_ids: List[str] | None = Field(
None, None,
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: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -67,12 +65,12 @@ 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
+13 -13
View File
@@ -1,7 +1,7 @@
"""Sync clock schemas (CRUD + control).""" """Sync clock schemas (CRUD + control)."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -11,14 +11,14 @@ class SyncClockCreate(BaseModel):
name: str = Field(description="Clock name", min_length=1, max_length=100) 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) 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: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.", description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -28,16 +28,16 @@ class SyncClockCreate(BaseModel):
class SyncClockUpdate(BaseModel): class SyncClockUpdate(BaseModel):
"""Request to update a synchronization clock.""" """Request to update a synchronization clock."""
name: Optional[str] = Field(None, description="Clock name", min_length=1, max_length=100) name: str | None = 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) speed: float | None = 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: str | None = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.", description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -50,14 +50,14 @@ class SyncClockResponse(BaseModel):
id: str = Field(description="Clock ID") id: str = Field(description="Clock ID")
name: str = Field(description="Clock name") name: str = Field(description="Clock name")
speed: float = Field(description="Speed multiplier") speed: float = Field(description="Speed multiplier")
description: Optional[str] = Field(None, description="Description") description: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library.", description="Icon id from the curated icon library.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon.", description="Optional CSS color override for the icon.",
+14 -14
View File
@@ -1,7 +1,7 @@
"""Capture template and engine schemas.""" """Capture template and engine schemas."""
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional from typing import Dict, List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -12,14 +12,14 @@ class TemplateCreate(BaseModel):
name: str = Field(description="Template name", min_length=1, max_length=100) name: str = Field(description="Template name", min_length=1, max_length=100)
engine_type: str = Field(description="Engine type (e.g., 'mss', 'dxcam', 'wgc')", min_length=1) 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") engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -29,17 +29,17 @@ class TemplateCreate(BaseModel):
class TemplateUpdate(BaseModel): class TemplateUpdate(BaseModel):
"""Request to update a template.""" """Request to update a template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: Optional[str] = Field(None, description="Capture engine type (mss, dxcam, wgc)") engine_type: str | None = Field(None, description="Capture engine type (mss, dxcam, wgc)")
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration") engine_config: Dict | None = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500) description: str | None = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -56,11 +56,11 @@ class TemplateResponse(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")
description: Optional[str] = Field(None, description="Template description") description: str | None = Field(None, description="Template description")
icon: Optional[str] = Field( icon: str | None = Field(
None, max_length=64, description="Icon id from the curated icon library." None, max_length=64, description="Icon id from the curated icon library."
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, max_length=32, description="Optional CSS color override for the icon." None, max_length=32, description="Optional CSS color override for the icon."
) )
+115 -123
View File
@@ -1,7 +1,7 @@
"""Value source schemas — discriminated unions per source type.""" """Value source schemas — discriminated unions per source type."""
from datetime import datetime from datetime import datetime
from typing import Annotated, List, Literal, Optional, Union from typing import Annotated, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag from pydantic import BaseModel, Discriminator, Field, Tag
@@ -15,14 +15,14 @@ class _ValueSourceResponseBase(BaseModel):
id: str = Field(description="Source ID") id: str = Field(description="Source ID")
name: str = Field(description="Source name") name: str = Field(description="Source name")
description: Optional[str] = Field(None, description="Description") description: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library.", description="Icon id from the curated icon library.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon.", description="Optional CSS color override for the icon.",
@@ -100,7 +100,7 @@ class AnimatedColorValueSourceResponse(_ValueSourceResponseBase):
colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]") colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]")
speed: float = Field(description="Cycles per minute (ignored when clock_id is set)") speed: float = Field(description="Cycles per minute (ignored when clock_id is set)")
easing: str = Field(description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine") easing: str = Field(description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine")
clock_id: Optional[str] = Field( clock_id: str | None = Field(
None, description="Optional sync clock ID for shared timing (overrides speed)" None, description="Optional sync clock ID for shared timing (overrides speed)"
) )
@@ -163,22 +163,20 @@ class HTTPValueSourceResponse(_ValueSourceResponseBase):
ValueSourceResponse = Annotated[ ValueSourceResponse = Annotated[
Union[ Annotated[StaticValueSourceResponse, Tag("static")]
Annotated[StaticValueSourceResponse, Tag("static")], | Annotated[AnimatedValueSourceResponse, Tag("animated")]
Annotated[AnimatedValueSourceResponse, Tag("animated")], | Annotated[AudioValueSourceResponse, Tag("audio")]
Annotated[AudioValueSourceResponse, Tag("audio")], | Annotated[AdaptiveTimeValueSourceResponse, Tag("adaptive_time")]
Annotated[AdaptiveTimeValueSourceResponse, Tag("adaptive_time")], | Annotated[AdaptiveSceneValueSourceResponse, Tag("adaptive_scene")]
Annotated[AdaptiveSceneValueSourceResponse, Tag("adaptive_scene")], | Annotated[DaylightValueSourceResponse, Tag("daylight")]
Annotated[DaylightValueSourceResponse, Tag("daylight")], | Annotated[StaticColorValueSourceResponse, Tag("static_color")]
Annotated[StaticColorValueSourceResponse, Tag("static_color")], | Annotated[AnimatedColorValueSourceResponse, Tag("animated_color")]
Annotated[AnimatedColorValueSourceResponse, Tag("animated_color")], | Annotated[AdaptiveTimeColorValueSourceResponse, Tag("adaptive_time_color")]
Annotated[AdaptiveTimeColorValueSourceResponse, Tag("adaptive_time_color")], | Annotated[HAEntityValueSourceResponse, Tag("ha_entity")]
Annotated[HAEntityValueSourceResponse, Tag("ha_entity")], | Annotated[GradientMapValueSourceResponse, Tag("gradient_map")]
Annotated[GradientMapValueSourceResponse, Tag("gradient_map")], | Annotated[CSSExtractValueSourceResponse, Tag("css_extract")]
Annotated[CSSExtractValueSourceResponse, Tag("css_extract")], | Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")]
Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")], | Annotated[HTTPValueSourceResponse, Tag("http")],
Annotated[HTTPValueSourceResponse, Tag("http")],
],
Discriminator("source_type"), Discriminator("source_type"),
] ]
@@ -191,14 +189,14 @@ class _ValueSourceCreateBase(BaseModel):
"""Shared fields for all value source create requests.""" """Shared fields for all value source create requests."""
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: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.", description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -276,7 +274,7 @@ class AnimatedColorValueSourceCreate(_ValueSourceCreateBase):
easing: str = Field( easing: str = Field(
"linear", description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine" "linear", description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
) )
clock_id: Optional[str] = Field( clock_id: str | None = Field(
None, description="Optional sync clock ID (overrides speed when set)" None, description="Optional sync clock ID (overrides speed when set)"
) )
@@ -333,22 +331,20 @@ class HTTPValueSourceCreate(_ValueSourceCreateBase):
ValueSourceCreate = Annotated[ ValueSourceCreate = Annotated[
Union[ Annotated[StaticValueSourceCreate, Tag("static")]
Annotated[StaticValueSourceCreate, Tag("static")], | Annotated[AnimatedValueSourceCreate, Tag("animated")]
Annotated[AnimatedValueSourceCreate, Tag("animated")], | Annotated[AudioValueSourceCreate, Tag("audio")]
Annotated[AudioValueSourceCreate, Tag("audio")], | Annotated[AdaptiveTimeValueSourceCreate, Tag("adaptive_time")]
Annotated[AdaptiveTimeValueSourceCreate, Tag("adaptive_time")], | Annotated[AdaptiveSceneValueSourceCreate, Tag("adaptive_scene")]
Annotated[AdaptiveSceneValueSourceCreate, Tag("adaptive_scene")], | Annotated[DaylightValueSourceCreate, Tag("daylight")]
Annotated[DaylightValueSourceCreate, Tag("daylight")], | Annotated[StaticColorValueSourceCreate, Tag("static_color")]
Annotated[StaticColorValueSourceCreate, Tag("static_color")], | Annotated[AnimatedColorValueSourceCreate, Tag("animated_color")]
Annotated[AnimatedColorValueSourceCreate, Tag("animated_color")], | Annotated[AdaptiveTimeColorValueSourceCreate, Tag("adaptive_time_color")]
Annotated[AdaptiveTimeColorValueSourceCreate, Tag("adaptive_time_color")], | Annotated[HAEntityValueSourceCreate, Tag("ha_entity")]
Annotated[HAEntityValueSourceCreate, Tag("ha_entity")], | Annotated[GradientMapValueSourceCreate, Tag("gradient_map")]
Annotated[GradientMapValueSourceCreate, Tag("gradient_map")], | Annotated[CSSExtractValueSourceCreate, Tag("css_extract")]
Annotated[CSSExtractValueSourceCreate, Tag("css_extract")], | Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")]
Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")], | Annotated[HTTPValueSourceCreate, Tag("http")],
Annotated[HTTPValueSourceCreate, Tag("http")],
],
Discriminator("source_type"), Discriminator("source_type"),
] ]
@@ -360,15 +356,15 @@ ValueSourceCreate = Annotated[
class _ValueSourceUpdateBase(BaseModel): class _ValueSourceUpdateBase(BaseModel):
"""Shared fields for all value source update requests.""" """Shared fields for all value source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.", description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -377,142 +373,138 @@ class _ValueSourceUpdateBase(BaseModel):
class StaticValueSourceUpdate(_ValueSourceUpdateBase): class StaticValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["static"] = "static" source_type: Literal["static"] = "static"
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0) value: float | None = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
class AnimatedValueSourceUpdate(_ValueSourceUpdateBase): class AnimatedValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["animated"] = "animated" source_type: Literal["animated"] = "animated"
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth") waveform: str | None = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0) speed: float | None = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0) min_value: float | None = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0) max_value: float | None = Field(None, description="Maximum output", ge=0.0, le=1.0)
class AudioValueSourceUpdate(_ValueSourceUpdateBase): class AudioValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["audio"] = "audio" source_type: Literal["audio"] = "audio"
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID") audio_source_id: str | None = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat") mode: str | None = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: Optional[float] = Field(None, description="Gain multiplier", ge=0.1, le=20.0) sensitivity: float | None = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing", ge=0.0, le=1.0) smoothing: float | None = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0) min_value: float | None = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0) max_value: float | None = Field(None, description="Maximum output", ge=0.0, le=1.0)
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels") auto_gain: bool | None = Field(None, description="Auto-normalize audio levels")
class AdaptiveTimeValueSourceUpdate(_ValueSourceUpdateBase): class AdaptiveTimeValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_time"] = "adaptive_time" source_type: Literal["adaptive_time"] = "adaptive_time"
schedule: Optional[list] = Field(None, description="Time-of-day schedule") schedule: list | None = Field(None, description="Time-of-day schedule")
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0) min_value: float | None = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0) max_value: float | None = Field(None, description="Maximum output", ge=0.0, le=1.0)
class AdaptiveSceneValueSourceUpdate(_ValueSourceUpdateBase): class AdaptiveSceneValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_scene"] = "adaptive_scene" source_type: Literal["adaptive_scene"] = "adaptive_scene"
picture_source_id: Optional[str] = Field(None, description="Picture source ID") picture_source_id: str | None = Field(None, description="Picture source ID")
scene_behavior: Optional[str] = Field(None, description="Scene behavior") scene_behavior: str | None = Field(None, description="Scene behavior")
sensitivity: Optional[float] = Field(None, description="Gain multiplier", ge=0.1, le=20.0) sensitivity: float | None = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing", ge=0.0, le=1.0) smoothing: float | None = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0) min_value: float | None = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0) max_value: float | None = Field(None, description="Maximum output", ge=0.0, le=1.0)
class DaylightValueSourceUpdate(_ValueSourceUpdateBase): class DaylightValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["daylight"] = "daylight" source_type: Literal["daylight"] = "daylight"
speed: Optional[float] = Field(None, description="Simulation speed", ge=0.1, le=120.0) speed: float | None = Field(None, description="Simulation speed", ge=0.1, le=120.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time") use_real_time: bool | None = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Geographic latitude", ge=-90.0, le=90.0) latitude: float | None = Field(None, description="Geographic latitude", ge=-90.0, le=90.0)
longitude: Optional[float] = Field( longitude: float | None = Field(None, description="Geographic longitude", ge=-180.0, le=180.0)
None, description="Geographic longitude", ge=-180.0, le=180.0 min_value: float | None = Field(None, description="Minimum output", ge=0.0, le=1.0)
) max_value: float | None = Field(None, description="Maximum output", ge=0.0, le=1.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
class StaticColorValueSourceUpdate(_ValueSourceUpdateBase): class StaticColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["static_color"] = "static_color" source_type: Literal["static_color"] = "static_color"
color: Optional[List[int]] = Field(None, description="Static RGB color [R,G,B]") color: List[int] | None = Field(None, description="Static RGB color [R,G,B]")
class AnimatedColorValueSourceUpdate(_ValueSourceUpdateBase): class AnimatedColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["animated_color"] = "animated_color" source_type: Literal["animated_color"] = "animated_color"
colors: Optional[List[List[int]]] = Field(None, description="Color list [[R,G,B], ...]") colors: List[List[int]] | None = Field(None, description="Color list [[R,G,B], ...]")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0) speed: float | None = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
easing: Optional[str] = Field( easing: str | None = Field(
None, description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine" None, description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
) )
clock_id: Optional[str] = Field( clock_id: str | None = Field(
None, description="Optional sync clock ID (empty string clears, null leaves unchanged)" None, description="Optional sync clock ID (empty string clears, null leaves unchanged)"
) )
class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase): class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_time_color"] = "adaptive_time_color" source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
schedule: Optional[list] = Field(None, description="Color schedule") schedule: list | None = Field(None, description="Color schedule")
class HAEntityValueSourceUpdate(_ValueSourceUpdateBase): class HAEntityValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["ha_entity"] = "ha_entity" source_type: Literal["ha_entity"] = "ha_entity"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID") ha_source_id: str | None = Field(None, description="Home Assistant source ID")
entity_id: Optional[str] = Field(None, description="HA entity ID") entity_id: str | None = Field(None, description="HA entity ID")
attribute: Optional[str] = Field(None, description="Attribute name") attribute: str | None = Field(None, description="Attribute name")
min_ha_value: Optional[float] = Field(None, description="Min HA value") min_ha_value: float | None = Field(None, description="Min HA value")
max_ha_value: Optional[float] = Field(None, description="Max HA value") max_ha_value: float | None = Field(None, description="Max HA value")
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0) smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
class GradientMapValueSourceUpdate(_ValueSourceUpdateBase): class GradientMapValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["gradient_map"] = "gradient_map" source_type: Literal["gradient_map"] = "gradient_map"
value_source_id: Optional[str] = Field(None, description="Input value source ID") value_source_id: str | None = Field(None, description="Input value source ID")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID") gradient_id: str | None = Field(None, description="Gradient entity ID")
easing: Optional[str] = Field(None, description="Interpolation mode") easing: str | None = Field(None, description="Interpolation mode")
class CSSExtractValueSourceUpdate(_ValueSourceUpdateBase): class CSSExtractValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["css_extract"] = "css_extract" source_type: Literal["css_extract"] = "css_extract"
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") color_strip_source_id: str | None = Field(None, description="Color strip source ID")
led_start: Optional[int] = Field(None, description="LED range start", ge=0) led_start: int | None = Field(None, description="LED range start", ge=0)
led_end: Optional[int] = Field(None, description="LED range end") led_end: int | None = Field(None, description="LED range end")
class SystemMetricsValueSourceUpdate(_ValueSourceUpdateBase): class SystemMetricsValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["system_metrics"] = "system_metrics" source_type: Literal["system_metrics"] = "system_metrics"
metric: Optional[str] = Field(None, description="System metric") metric: str | None = Field(None, description="System metric")
min_value: Optional[float] = Field(None, description="Min value") min_value: float | None = Field(None, description="Min value")
max_value: Optional[float] = Field(None, description="Max value") max_value: float | None = Field(None, description="Max value")
max_rate: Optional[float] = Field(None, description="Max rate bytes/sec") max_rate: float | None = Field(None, description="Max rate bytes/sec")
disk_path: Optional[str] = Field(None, description="Disk path") disk_path: str | None = Field(None, description="Disk path")
sensor_label: Optional[str] = Field(None, description="Sensor label") sensor_label: str | None = Field(None, description="Sensor label")
poll_interval: Optional[float] = Field(None, description="Poll interval", ge=0.1, le=60.0) poll_interval: float | None = Field(None, description="Poll interval", ge=0.1, le=60.0)
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0) smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
class HTTPValueSourceUpdate(_ValueSourceUpdateBase): class HTTPValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["http"] = "http" source_type: Literal["http"] = "http"
http_endpoint_id: Optional[str] = Field(None, description="HTTP endpoint ID") http_endpoint_id: str | None = Field(None, description="HTTP endpoint ID")
json_path: Optional[str] = Field(None, description="Dot-path into the response") json_path: str | None = Field(None, description="Dot-path into the response")
interval_s: Optional[int] = Field(None, description="Polling cadence (seconds)", ge=1) interval_s: int | None = Field(None, description="Polling cadence (seconds)", ge=1)
min_value: Optional[float] = Field(None, description="Raw value mapped to 0.0") min_value: float | None = Field(None, description="Raw value mapped to 0.0")
max_value: Optional[float] = Field(None, description="Raw value mapped to 1.0") max_value: float | None = Field(None, description="Raw value mapped to 1.0")
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0) smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
ValueSourceUpdate = Annotated[ ValueSourceUpdate = Annotated[
Union[ Annotated[StaticValueSourceUpdate, Tag("static")]
Annotated[StaticValueSourceUpdate, Tag("static")], | Annotated[AnimatedValueSourceUpdate, Tag("animated")]
Annotated[AnimatedValueSourceUpdate, Tag("animated")], | Annotated[AudioValueSourceUpdate, Tag("audio")]
Annotated[AudioValueSourceUpdate, Tag("audio")], | Annotated[AdaptiveTimeValueSourceUpdate, Tag("adaptive_time")]
Annotated[AdaptiveTimeValueSourceUpdate, Tag("adaptive_time")], | Annotated[AdaptiveSceneValueSourceUpdate, Tag("adaptive_scene")]
Annotated[AdaptiveSceneValueSourceUpdate, Tag("adaptive_scene")], | Annotated[DaylightValueSourceUpdate, Tag("daylight")]
Annotated[DaylightValueSourceUpdate, Tag("daylight")], | Annotated[StaticColorValueSourceUpdate, Tag("static_color")]
Annotated[StaticColorValueSourceUpdate, Tag("static_color")], | Annotated[AnimatedColorValueSourceUpdate, Tag("animated_color")]
Annotated[AnimatedColorValueSourceUpdate, Tag("animated_color")], | Annotated[AdaptiveTimeColorValueSourceUpdate, Tag("adaptive_time_color")]
Annotated[AdaptiveTimeColorValueSourceUpdate, Tag("adaptive_time_color")], | Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")]
Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")], | Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")]
Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")], | Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")]
Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")], | Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")]
Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")], | Annotated[HTTPValueSourceUpdate, Tag("http")],
Annotated[HTTPValueSourceUpdate, Tag("http")],
],
Discriminator("source_type"), Discriminator("source_type"),
] ]
@@ -1,7 +1,7 @@
"""Weather source schemas (CRUD).""" """Weather source schemas (CRUD)."""
from datetime import datetime from datetime import datetime
from typing import Dict, List, Literal, Optional from typing import Dict, List, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -13,7 +13,7 @@ class WeatherSourceCreate(BaseModel):
provider: Literal["open_meteo"] = Field( provider: Literal["open_meteo"] = Field(
default="open_meteo", description="Weather data provider" default="open_meteo", description="Weather data provider"
) )
provider_config: Optional[Dict] = Field(None, description="Provider-specific configuration") provider_config: Dict | None = Field(None, description="Provider-specific configuration")
latitude: float = Field( latitude: float = Field(
default=50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0 default=50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0
) )
@@ -23,14 +23,14 @@ class WeatherSourceCreate(BaseModel):
update_interval: int = Field( update_interval: int = Field(
default=600, description="API poll interval in seconds (60-3600)", ge=60, le=3600 default=600, description="API poll interval in seconds (60-3600)", ge=60, le=3600
) )
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.", description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -40,26 +40,26 @@ class WeatherSourceCreate(BaseModel):
class WeatherSourceUpdate(BaseModel): class WeatherSourceUpdate(BaseModel):
"""Request to update a weather source.""" """Request to update a weather source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
provider: Optional[Literal["open_meteo"]] = Field(None, description="Weather data provider") provider: Literal["open_meteo"] | None = Field(None, description="Weather data provider")
provider_config: Optional[Dict] = Field(None, description="Provider-specific configuration") provider_config: Dict | None = Field(None, description="Provider-specific configuration")
latitude: Optional[float] = Field( latitude: float | None = Field(
None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0 None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0
) )
longitude: Optional[float] = Field( longitude: float | None = Field(
None, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0 None, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0
) )
update_interval: Optional[int] = Field( update_interval: int | None = Field(
None, description="API poll interval in seconds (60-3600)", ge=60, le=3600 None, description="API poll interval in seconds (60-3600)", ge=60, le=3600
) )
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: str | None = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: List[str] | None = None
icon: Optional[str] = Field( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.", description="Icon id from the curated icon library. Pass empty string to clear.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.", description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -78,14 +78,14 @@ class WeatherSourceResponse(BaseModel):
latitude: float = Field(description="Geographic latitude") latitude: float = Field(description="Geographic latitude")
longitude: float = Field(description="Geographic longitude") longitude: float = Field(description="Geographic longitude")
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: str | None = 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( icon: str | None = Field(
None, None,
max_length=64, max_length=64,
description="Icon id from the curated icon library.", description="Icon id from the curated icon library.",
) )
icon_color: Optional[str] = Field( icon_color: str | None = Field(
None, None,
max_length=32, max_length=32,
description="Optional CSS color override for the icon.", description="Optional CSS color override for the icon.",
@@ -11,7 +11,7 @@ capture stream (WASAPI, sounddevice, etc.).
import threading import threading
import time import time
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Tuple
from ledgrab.core.audio.analysis import ( from ledgrab.core.audio.analysis import (
AudioAnalysis, AudioAnalysis,
@@ -49,7 +49,7 @@ class ManagedAudioStream:
engine_type: str, engine_type: str,
device_index: int, device_index: int,
is_loopback: bool, is_loopback: bool,
engine_config: Optional[Dict[str, Any]] = None, engine_config: Dict[str, Any] | None = None,
): ):
self._engine_type = engine_type self._engine_type = engine_type
self._device_index = device_index self._device_index = device_index
@@ -57,9 +57,9 @@ class ManagedAudioStream:
self._engine_config = engine_config or {} self._engine_config = engine_config or {}
self._running = False self._running = False
self._thread: Optional[threading.Thread] = None self._thread: threading.Thread | None = None
self._lock = threading.Lock() self._lock = threading.Lock()
self._latest: Optional[AudioAnalysis] = None self._latest: AudioAnalysis | None = None
self._last_timing: dict = {} self._last_timing: dict = {}
def start(self) -> None: def start(self) -> None:
@@ -90,7 +90,7 @@ class ManagedAudioStream:
f"device={self._device_index}" f"device={self._device_index}"
) )
def get_latest_analysis(self) -> Optional[AudioAnalysis]: def get_latest_analysis(self) -> AudioAnalysis | None:
with self._lock: with self._lock:
return self._latest return self._latest
@@ -98,7 +98,7 @@ class ManagedAudioStream:
return dict(self._last_timing) return dict(self._last_timing)
def _capture_loop(self) -> None: def _capture_loop(self) -> None:
stream: Optional[AudioCaptureStreamBase] = None stream: AudioCaptureStreamBase | None = None
try: try:
stream = AudioEngineRegistry.create_stream( stream = AudioEngineRegistry.create_stream(
self._engine_type, self._engine_type,
@@ -178,8 +178,8 @@ class AudioCaptureManager:
self, self,
device_index: int, device_index: int,
is_loopback: bool, is_loopback: bool,
engine_type: Optional[str] = None, engine_type: str | None = None,
engine_config: Optional[Dict[str, Any]] = None, engine_config: Dict[str, Any] | None = None,
) -> ManagedAudioStream: ) -> ManagedAudioStream:
"""Get or create a ManagedAudioStream for the given device. """Get or create a ManagedAudioStream for the given device.
@@ -220,7 +220,7 @@ class AudioCaptureManager:
self, self,
device_index: int, device_index: int,
is_loopback: bool, is_loopback: bool,
engine_type: Optional[str] = None, engine_type: str | None = None,
) -> None: ) -> None:
"""Release a reference to a ManagedAudioStream.""" """Release a reference to a ManagedAudioStream."""
if engine_type is None: if engine_type is None:
+2 -2
View File
@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
import numpy as np import numpy as np
@@ -83,7 +83,7 @@ class AudioCaptureStreamBase(ABC):
pass pass
@abstractmethod @abstractmethod
def read_chunk(self) -> Optional[np.ndarray]: def read_chunk(self) -> np.ndarray | None:
"""Read one chunk of raw audio data. """Read one chunk of raw audio data.
Returns: Returns:
+2 -2
View File
@@ -1,7 +1,7 @@
"""Demo audio engine — virtual audio devices with synthetic audio data.""" """Demo audio engine — virtual audio devices with synthetic audio data."""
import time import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
import numpy as np import numpy as np
@@ -62,7 +62,7 @@ class DemoAudioCaptureStream(AudioCaptureStreamBase):
self._initialized = False self._initialized = False
logger.info(f"Demo audio stream cleaned up (device={self.device_index})") logger.info(f"Demo audio stream cleaned up (device={self.device_index})")
def read_chunk(self) -> Optional[np.ndarray]: def read_chunk(self) -> np.ndarray | None:
if not self._initialized: if not self._initialized:
return None return None
+2 -2
View File
@@ -1,6 +1,6 @@
"""Engine registry and factory for audio capture engines.""" """Engine registry and factory for audio capture engines."""
from typing import Any, Dict, List, Optional, Type from typing import Any, Dict, List, Type
from ledgrab.core.audio.base import AudioCaptureEngine, AudioCaptureStreamBase from ledgrab.core.audio.base import AudioCaptureEngine, AudioCaptureStreamBase
from ledgrab.config import is_demo_mode from ledgrab.config import is_demo_mode
@@ -82,7 +82,7 @@ class AudioEngineRegistry:
return available return available
@classmethod @classmethod
def get_best_available_engine(cls) -> Optional[str]: def get_best_available_engine(cls) -> str | None:
"""Get the highest-priority available engine type. """Get the highest-priority available engine type.
Returns: Returns:
@@ -8,7 +8,6 @@ from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry from ledgrab.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.band_filter import apply_band_filter, compute_band_mask from ledgrab.core.audio.band_filter import apply_band_filter, compute_band_mask
# Preset frequency ranges # Preset frequency ranges
_PRESETS = { _PRESETS = {
"bass": (20.0, 250.0), "bass": (20.0, 250.0),
@@ -4,7 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
from ledgrab.core.audio.analysis import AudioAnalysis from ledgrab.core.audio.analysis import AudioAnalysis
@@ -20,8 +20,8 @@ class AudioFilterOptionDef:
min_value: Any min_value: Any
max_value: Any max_value: Any
step: Any step: Any
choices: Optional[List[Dict[str, str]]] = None # for "select": [{value, label}] choices: List[Dict[str, str]] | None = None # for "select": [{value, label}]
max_length: Optional[int] = None # for "string" type max_length: int | None = None # for "string" type
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = { d = {
@@ -1,6 +1,6 @@
"""Sounddevice audio capture engine (cross-platform, via PortAudio).""" """Sounddevice audio capture engine (cross-platform, via PortAudio)."""
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
import numpy as np import numpy as np
@@ -79,7 +79,7 @@ class SounddeviceCaptureStream(AudioCaptureStreamBase):
self._sd_stream = None self._sd_stream = None
self._initialized = False self._initialized = False
def read_chunk(self) -> Optional[np.ndarray]: def read_chunk(self) -> np.ndarray | None:
if self._sd_stream is None: if self._sd_stream is None:
return None return None
try: try:
@@ -1,6 +1,6 @@
"""WASAPI audio capture engine (Windows only, via PyAudioWPatch).""" """WASAPI audio capture engine (Windows only, via PyAudioWPatch)."""
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
import numpy as np import numpy as np
@@ -98,7 +98,7 @@ class WasapiCaptureStream(AudioCaptureStreamBase):
self._pa = None self._pa = None
self._initialized = False self._initialized = False
def read_chunk(self) -> Optional[np.ndarray]: def read_chunk(self) -> np.ndarray | None:
if self._stream is None: if self._stream is None:
return None return None
try: try:
@@ -109,7 +109,7 @@ class WasapiCaptureStream(AudioCaptureStreamBase):
return None return None
@staticmethod @staticmethod
def _find_loopback_device(pa, output_device_index: int) -> Optional[dict]: def _find_loopback_device(pa, output_device_index: int) -> dict | None:
"""Find the PyAudioWPatch loopback device for a given output device.""" """Find the PyAudioWPatch loopback device for a given output device."""
try: try:
first_loopback = None first_loopback = None
@@ -4,7 +4,7 @@ import asyncio
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Callable, Dict, Optional, Set from typing import Callable, Dict, Set
from ledgrab.core.automations.platform_detector import PlatformDetector from ledgrab.core.automations.platform_detector import PlatformDetector
from ledgrab.storage.automation import ( from ledgrab.storage.automation import (
@@ -38,11 +38,11 @@ class _RuleEvalContext:
""" """
running_procs: Set[str] running_procs: Set[str]
topmost_proc: Optional[str] topmost_proc: str | None
topmost_fullscreen: bool topmost_fullscreen: bool
fullscreen_procs: Set[str] fullscreen_procs: Set[str]
idle_seconds: Optional[float] idle_seconds: float | None
display_state: Optional[str] display_state: str | None
def _apply_operator(operator: str, extracted, expected: str) -> bool: def _apply_operator(operator: str, extracted, expected: str) -> bool:
@@ -101,7 +101,7 @@ class AutomationEngine:
self._target_store = target_store self._target_store = target_store
self._device_store = device_store self._device_store = device_store
self._ha_manager = ha_manager self._ha_manager = ha_manager
self._task: Optional[asyncio.Task] = None self._task: asyncio.Task | None = None
self._eval_lock = asyncio.Lock() self._eval_lock = asyncio.Lock()
# Runtime state (not persisted) # Runtime state (not persisted)
@@ -420,11 +420,11 @@ class AutomationEngine:
self, self,
automation: Automation, automation: Automation,
running_procs: Set[str], running_procs: Set[str],
topmost_proc: Optional[str], topmost_proc: str | None,
topmost_fullscreen: bool, topmost_fullscreen: bool,
fullscreen_procs: Set[str], fullscreen_procs: Set[str],
idle_seconds: Optional[float], idle_seconds: float | None,
display_state: Optional[str], display_state: str | None,
) -> bool: ) -> bool:
results = [ results = [
self._evaluate_rule( self._evaluate_rule(
@@ -453,11 +453,11 @@ class AutomationEngine:
self, self,
rule: Rule, rule: Rule,
running_procs: Set[str], running_procs: Set[str],
topmost_proc: Optional[str], topmost_proc: str | None,
topmost_fullscreen: bool, topmost_fullscreen: bool,
fullscreen_procs: Set[str], fullscreen_procs: Set[str],
idle_seconds: Optional[float], idle_seconds: float | None,
display_state: Optional[str], display_state: str | None,
) -> bool: ) -> bool:
ctx = _RuleEvalContext( ctx = _RuleEvalContext(
running_procs=running_procs, running_procs=running_procs,
@@ -531,14 +531,14 @@ class AutomationEngine:
return current >= start or current <= end return current >= start or current <= end
@staticmethod @staticmethod
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: Optional[float]) -> bool: def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
if idle_seconds is None: if idle_seconds is None:
return False return False
is_idle = idle_seconds >= (rule.idle_minutes * 60) is_idle = idle_seconds >= (rule.idle_minutes * 60)
return is_idle if rule.when_idle else not is_idle return is_idle if rule.when_idle else not is_idle
@staticmethod @staticmethod
def _evaluate_display_state(rule: DisplayStateRule, display_state: Optional[str]) -> bool: def _evaluate_display_state(rule: DisplayStateRule, display_state: str | None) -> bool:
if display_state is None: if display_state is None:
return False return False
return display_state == rule.state return display_state == rule.state
@@ -612,7 +612,7 @@ class AutomationEngine:
self, self,
rule: ApplicationRule, rule: ApplicationRule,
running_procs: Set[str], running_procs: Set[str],
topmost_proc: Optional[str], topmost_proc: str | None,
topmost_fullscreen: bool, topmost_fullscreen: bool,
fullscreen_procs: Set[str], fullscreen_procs: Set[str],
) -> bool: ) -> bool:
@@ -9,7 +9,7 @@ import ctypes
import os import os
import sys import sys
import threading import threading
from typing import Optional, Set from typing import Set
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
@@ -164,7 +164,7 @@ class PlatformDetector:
except Exception as e: except Exception as e:
logger.error(f"Display power listener failed: {e}") logger.error(f"Display power listener failed: {e}")
def _get_display_power_state_sync(self) -> Optional[str]: def _get_display_power_state_sync(self) -> str | None:
"""Get display power state: 'on' or 'off'. Returns None if unavailable.""" """Get display power state: 'on' or 'off'. Returns None if unavailable."""
if not _IS_WINDOWS: if not _IS_WINDOWS:
return None return None
@@ -172,7 +172,7 @@ class PlatformDetector:
# ---- System idle detection ---- # ---- System idle detection ----
def _get_idle_seconds_sync(self) -> Optional[float]: def _get_idle_seconds_sync(self) -> float | None:
"""Get system idle time in seconds (keyboard/mouse inactivity). """Get system idle time in seconds (keyboard/mouse inactivity).
Returns None if detection is unavailable. Returns None if detection is unavailable.
@@ -4,7 +4,7 @@ import asyncio
import os import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List
from ledgrab.storage.database import Database from ledgrab.storage.database import Database
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
@@ -33,8 +33,8 @@ class AutoBackupEngine:
): ):
self._backup_dir = Path(backup_dir) self._backup_dir = Path(backup_dir)
self._db = db self._db = db
self._task: Optional[asyncio.Task] = None self._task: asyncio.Task | None = None
self._last_backup_time: Optional[datetime] = None self._last_backup_time: datetime | None = None
self._settings = self._load_settings() self._settings = self._load_settings()
self._backup_dir.mkdir(parents=True, exist_ok=True) self._backup_dir.mkdir(parents=True, exist_ok=True)
@@ -1,13 +1,13 @@
"""Pixel processing utilities for color correction and manipulation.""" """Pixel processing utilities for color correction and manipulation."""
from typing import List, Tuple, Union from typing import List, Tuple
import numpy as np import numpy as np
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
ColorList = Union[List[Tuple[int, int, int]], np.ndarray] ColorList = List[Tuple[int, int, int]] | np.ndarray
def _as_array(colors: ColorList) -> np.ndarray: def _as_array(colors: ColorList) -> np.ndarray:
@@ -6,7 +6,7 @@ import colorsys
import logging import logging
import sys import sys
import threading import threading
from typing import TYPE_CHECKING, Dict, List, Optional from typing import TYPE_CHECKING, Dict, List
if TYPE_CHECKING: if TYPE_CHECKING:
import tkinter as tk import tkinter as tk
@@ -41,8 +41,8 @@ class OverlayWindow:
self.calibration = calibration self.calibration = calibration
self.target_id = target_id self.target_id = target_id
self.target_name = target_name or target_id self.target_name = target_name or target_id
self._window: Optional[tk.Toplevel] = None self._window: tk.Toplevel | None = None
self._canvas: Optional[tk.Canvas] = None self._canvas: tk.Canvas | None = None
self.running = False self.running = False
# ----- Lifecycle (must run in Tk thread) ----- # ----- Lifecycle (must run in Tk thread) -----
@@ -352,8 +352,8 @@ class OverlayManager:
def __init__(self): def __init__(self):
self._overlays: Dict[str, OverlayWindow] = {} self._overlays: Dict[str, OverlayWindow] = {}
self._lock = threading.Lock() self._lock = threading.Lock()
self._tk_root: Optional[tk.Tk] = None self._tk_root: tk.Tk | None = None
self._tk_thread: Optional[threading.Thread] = None self._tk_thread: threading.Thread | None = None
self._tk_ready = threading.Event() self._tk_ready = threading.Event()
self._start_tk_thread() self._start_tk_thread()
@@ -386,7 +386,7 @@ class OverlayManager:
if self._tk_root is None: if self._tk_root is None:
raise RuntimeError("Tkinter root not available") raise RuntimeError("Tkinter root not available")
done = threading.Event() done = threading.Event()
exc_box: List[Optional[BaseException]] = [None] exc_box: List[BaseException | None] = [None]
def wrapper(): def wrapper():
try: try:
@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
import numpy as np import numpy as np
@@ -70,7 +70,7 @@ class CaptureStream(ABC):
pass pass
@abstractmethod @abstractmethod
def capture_frame(self) -> Optional[ScreenCapture]: def capture_frame(self) -> ScreenCapture | None:
"""Capture one frame from the bound display. """Capture one frame from the bound display.
Returns: Returns:
@@ -2,7 +2,7 @@
import sys import sys
import time import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
from ledgrab.core.capture_engines.base import ( from ledgrab.core.capture_engines.base import (
@@ -104,7 +104,7 @@ class BetterCamCaptureStream(CaptureStream):
logger.error(f"BetterCam reinit failed (display={self.display_index}): {reinit_err}") logger.error(f"BetterCam reinit failed (display={self.display_index}): {reinit_err}")
return False return False
def capture_frame(self) -> Optional[ScreenCapture]: def capture_frame(self) -> ScreenCapture | None:
if not self._initialized: if not self._initialized:
self.initialize() self.initialize()
@@ -13,7 +13,7 @@ import platform
import sys import sys
import threading import threading
import time import time
from typing import Any, Dict, List, Optional, Set from typing import Any, Dict, List, Set
# OpenCV's MSMF backend on Windows often fails to open the device # OpenCV's MSMF backend on Windows often fails to open the device
# ("cap.isOpened() == False" right after VideoCapture returns) when # ("cap.isOpened() == False" right after VideoCapture returns) when
@@ -50,7 +50,7 @@ _RESOLUTION_CHOICES: List[str] = [
] ]
def _parse_resolution(value: Any) -> Optional[tuple[int, int]]: def _parse_resolution(value: Any) -> tuple[int, int] | None:
"""Parse a 'WxH' string into (width, height). Returns None for 'auto' or invalid.""" """Parse a 'WxH' string into (width, height). Returns None for 'auto' or invalid."""
if not isinstance(value, str): if not isinstance(value, str):
return None return None
@@ -101,7 +101,7 @@ _BUILDINFO_LABELS: Dict[str, str] = {
"avfoundation": "AVFoundation", "avfoundation": "AVFoundation",
} }
_compiled_backends_cache: Optional[Set[str]] = None _compiled_backends_cache: Set[str] | None = None
def _get_compiled_backends() -> Set[str]: def _get_compiled_backends() -> Set[str]:
@@ -169,7 +169,7 @@ def _get_supported_backends() -> List[str]:
return ["auto", *(b for b in candidates if b in compiled)] return ["auto", *(b for b in candidates if b in compiled)]
def _cv2_backend_id(backend_name: str) -> Optional[int]: def _cv2_backend_id(backend_name: str) -> int | None:
"""Convert a backend name string to cv2 API preference constant.""" """Convert a backend name string to cv2 API preference constant."""
return _CV2_BACKENDS.get(backend_name) return _CV2_BACKENDS.get(backend_name)
@@ -307,7 +307,7 @@ def _get_camera_friendly_names() -> Dict[int, str]:
return {} return {}
_camera_cache: Optional[List[Dict[str, Any]]] = None _camera_cache: List[Dict[str, Any]] | None = None
_camera_cache_time: float = 0 _camera_cache_time: float = 0
_CAMERA_CACHE_TTL = 30.0 # seconds _CAMERA_CACHE_TTL = 30.0 # seconds
@@ -428,7 +428,7 @@ class CameraCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]): def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config) super().__init__(display_index, config)
self._cap = None self._cap = None
self._cv2_index: Optional[int] = None self._cv2_index: int | None = None
def initialize(self) -> None: def initialize(self) -> None:
if self._initialized: if self._initialized:
@@ -531,7 +531,7 @@ class CameraCaptureStream(CaptureStream):
f"(camera={camera['name']}, cv2_idx={cv2_index}, {w}x{h})" f"(camera={camera['name']}, cv2_idx={cv2_index}, {w}x{h})"
) )
def capture_frame(self) -> Optional[ScreenCapture]: def capture_frame(self) -> ScreenCapture | None:
if not self._initialized: if not self._initialized:
self.initialize() self.initialize()
@@ -1,7 +1,7 @@
"""Demo capture engine — virtual displays with animated test patterns.""" """Demo capture engine — virtual displays with animated test patterns."""
import time import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
import numpy as np import numpy as np
@@ -65,7 +65,7 @@ class DemoCaptureStream(CaptureStream):
self._initialized = False self._initialized = False
logger.info(f"Demo capture stream cleaned up (display={self.display_index})") logger.info(f"Demo capture stream cleaned up (display={self.display_index})")
def capture_frame(self) -> Optional[ScreenCapture]: def capture_frame(self) -> ScreenCapture | None:
if not self._initialized: if not self._initialized:
self.initialize() self.initialize()
@@ -2,7 +2,7 @@
import sys import sys
import time import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
from ledgrab.core.capture_engines.base import ( from ledgrab.core.capture_engines.base import (
@@ -102,7 +102,7 @@ class DXcamCaptureStream(CaptureStream):
logger.error(f"DXcam reinit failed (display={self.display_index}): {reinit_err}") logger.error(f"DXcam reinit failed (display={self.display_index}): {reinit_err}")
return False return False
def capture_frame(self) -> Optional[ScreenCapture]: def capture_frame(self) -> ScreenCapture | None:
if not self._initialized: if not self._initialized:
self.initialize() self.initialize()
@@ -1,6 +1,6 @@
"""Engine registry and factory for screen capture engines.""" """Engine registry and factory for screen capture engines."""
from typing import Any, Dict, List, Optional, Type from typing import Any, Dict, List, Type
from ledgrab.core.capture_engines.base import CaptureEngine, CaptureStream from ledgrab.core.capture_engines.base import CaptureEngine, CaptureStream
from ledgrab.config import is_demo_mode from ledgrab.config import is_demo_mode
@@ -83,7 +83,7 @@ class EngineRegistry:
return available return available
@classmethod @classmethod
def get_best_available_engine(cls) -> Optional[str]: def get_best_available_engine(cls) -> str | None:
"""Get the highest-priority available engine type. """Get the highest-priority available engine type.
Returns: Returns:
@@ -29,7 +29,7 @@ logger = get_logger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_frame_queue: queue.Queue["ScreenCapture"] = queue.Queue(maxsize=2) _frame_queue: queue.Queue["ScreenCapture"] = queue.Queue(maxsize=2)
_display_info: Optional[DisplayInfo] = None _display_info: DisplayInfo | None = None
_active = False _active = False
_frames_received = 0 _frames_received = 0
_frames_consumed = 0 _frames_consumed = 0
@@ -141,7 +141,7 @@ class MediaProjectionCaptureStream(CaptureStream):
self._initialized = True self._initialized = True
logger.info("MediaProjection capture stream initialized") logger.info("MediaProjection capture stream initialized")
def capture_frame(self) -> Optional[ScreenCapture]: def capture_frame(self) -> ScreenCapture | None:
if not self._initialized: if not self._initialized:
self.initialize() self.initialize()
# Prefer fresh frames from the queue; fall back to the last # Prefer fresh frames from the queue; fall back to the last
@@ -1,6 +1,6 @@
"""MSS-based screen capture engine (cross-platform).""" """MSS-based screen capture engine (cross-platform)."""
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
import mss import mss
import numpy as np import numpy as np
@@ -41,7 +41,7 @@ class MSSCaptureStream(CaptureStream):
self._rgb_idx: int = 0 self._rgb_idx: int = 0
self._rgb_shape: tuple = (0, 0) self._rgb_shape: tuple = (0, 0)
# Cheap hash of the previous .raw bytes, for change detection. # Cheap hash of the previous .raw bytes, for change detection.
self._prev_hash: Optional[int] = None self._prev_hash: int | None = None
def initialize(self) -> None: def initialize(self) -> None:
try: try:
@@ -59,7 +59,7 @@ class MSSCaptureStream(CaptureStream):
self._prev_hash = None self._prev_hash = None
logger.info(f"MSS capture stream cleaned up (display={self.display_index})") logger.info(f"MSS capture stream cleaned up (display={self.display_index})")
def capture_frame(self) -> Optional[ScreenCapture]: def capture_frame(self) -> ScreenCapture | None:
if not self._initialized: if not self._initialized:
self.initialize() self.initialize()
@@ -35,7 +35,7 @@ logger = get_logger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_frame_queue: queue.Queue["ScreenCapture"] = queue.Queue(maxsize=2) _frame_queue: queue.Queue["ScreenCapture"] = queue.Queue(maxsize=2)
_display_info: Optional[DisplayInfo] = None _display_info: DisplayInfo | None = None
_active = False _active = False
_frames_received = 0 _frames_received = 0
# screenrecord emits a full bitstream every frame (keyframes aside), so # screenrecord emits a full bitstream every frame (keyframes aside), so
@@ -123,7 +123,7 @@ class RootScreenrecordCaptureStream(CaptureStream):
self._initialized = True self._initialized = True
logger.info("Root screenrecord capture stream initialized") logger.info("Root screenrecord capture stream initialized")
def capture_frame(self) -> Optional[ScreenCapture]: def capture_frame(self) -> ScreenCapture | None:
if not self._initialized: if not self._initialized:
self.initialize() self.initialize()
try: try:
@@ -14,7 +14,7 @@ video stream. No APK installation, no root.
""" """
import threading import threading
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
import numpy as np import numpy as np
@@ -96,12 +96,12 @@ class ScrcpyClientCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]): def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config) super().__init__(display_index, config)
self._client: Optional["scrcpy.Client"] = None self._client: "scrcpy.Client" | None = None
self._latest_frame: Optional[ScreenCapture] = None self._latest_frame: ScreenCapture | None = None
self._frame_lock = threading.Lock() self._frame_lock = threading.Lock()
self._frame_event = threading.Event() self._frame_event = threading.Event()
self._client_thread: Optional[threading.Thread] = None self._client_thread: threading.Thread | None = None
self._device_serial: Optional[str] = None self._device_serial: str | None = None
def initialize(self) -> None: def initialize(self) -> None:
if self._initialized: if self._initialized:
@@ -189,7 +189,7 @@ class ScrcpyClientCaptureStream(CaptureStream):
) )
self._frame_event.set() self._frame_event.set()
def capture_frame(self) -> Optional[ScreenCapture]: def capture_frame(self) -> ScreenCapture | None:
if not self._initialized: if not self._initialized:
self.initialize() self.initialize()
@@ -18,7 +18,7 @@ import shutil
import subprocess import subprocess
import threading import threading
import time import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
import numpy as np import numpy as np
@@ -78,7 +78,7 @@ def _find_adb() -> str:
return "adb" # last resort — will fail with FileNotFoundError return "adb" # last resort — will fail with FileNotFoundError
_adb_path: Optional[str] = None _adb_path: str | None = None
def _get_adb() -> str: def _get_adb() -> str:
@@ -158,7 +158,7 @@ def _list_adb_devices() -> List[Dict[str, Any]]:
return devices return devices
def _screencap_once(adb: str, serial: str) -> Optional[np.ndarray]: def _screencap_once(adb: str, serial: str) -> np.ndarray | None:
"""Capture a single PNG screenshot and return it as an RGB NumPy array.""" """Capture a single PNG screenshot and return it as an RGB NumPy array."""
try: try:
result = subprocess.run( result = subprocess.run(
@@ -190,12 +190,12 @@ class ScrcpyCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]): def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config) super().__init__(display_index, config)
self._capture_thread: Optional[threading.Thread] = None self._capture_thread: threading.Thread | None = None
self._latest_frame: Optional[ScreenCapture] = None self._latest_frame: ScreenCapture | None = None
self._frame_lock = threading.Lock() self._frame_lock = threading.Lock()
self._frame_event = threading.Event() self._frame_event = threading.Event()
self._running = False self._running = False
self._device_serial: Optional[str] = None self._device_serial: str | None = None
def initialize(self) -> None: def initialize(self) -> None:
if self._initialized: if self._initialized:
@@ -281,7 +281,7 @@ class ScrcpyCaptureStream(CaptureStream):
if poll_interval > 0: if poll_interval > 0:
time.sleep(poll_interval) time.sleep(poll_interval)
def capture_frame(self) -> Optional[ScreenCapture]: def capture_frame(self) -> ScreenCapture | None:
if not self._initialized: if not self._initialized:
self.initialize() self.initialize()
@@ -3,7 +3,7 @@
import gc import gc
import sys import sys
import threading import threading
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
import numpy as np import numpy as np
@@ -199,7 +199,7 @@ class WGCCaptureStream(CaptureStream):
gc.collect(0) gc.collect(0)
logger.info(f"WGC capture stream cleaned up (display={self.display_index})") logger.info(f"WGC capture stream cleaned up (display={self.display_index})")
def capture_frame(self) -> Optional[ScreenCapture]: def capture_frame(self) -> ScreenCapture | None:
if not self._initialized: if not self._initialized:
self.initialize() self.initialize()
@@ -3,7 +3,7 @@
import asyncio import asyncio
import concurrent.futures import concurrent.futures
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional, Tuple from typing import Tuple
import numpy as np import numpy as np
@@ -41,7 +41,7 @@ def _build_adalight_header(led_count: int) -> bytes:
class AdalightClient(LEDClient): class AdalightClient(LEDClient):
"""LED client for Arduino Adalight serial devices.""" """LED client for Arduino Adalight serial devices."""
def __init__(self, url: str, led_count: int = 0, baud_rate: Optional[int] = None, **kwargs): def __init__(self, url: str, led_count: int = 0, baud_rate: int | None = None, **kwargs):
"""Initialize Adalight client. """Initialize Adalight client.
Args: Args:
@@ -62,11 +62,11 @@ class AdalightClient(LEDClient):
# Pre-allocated wire buffer (header + RGB payload). Resized on the # Pre-allocated wire buffer (header + RGB payload). Resized on the
# first frame and reused thereafter so the hot path performs no # first frame and reused thereafter so the hot path performs no
# allocations — only a single memcpy of the pixel bytes. # allocations — only a single memcpy of the pixel bytes.
self._frame_buf: Optional[bytearray] = None self._frame_buf: bytearray | None = None
self._frame_buf_n: int = 0 self._frame_buf_n: int = 0
# Scratch uint8 array used to coerce non-uint8 / non-contiguous input # Scratch uint8 array used to coerce non-uint8 / non-contiguous input
# without allocating a fresh array per frame. # without allocating a fresh array per frame.
self._u8_scratch: Optional[np.ndarray] = None self._u8_scratch: np.ndarray | None = None
self._u8_scratch_n: int = 0 self._u8_scratch_n: int = 0
# Dedicated single-worker executor for serial writes. Using # Dedicated single-worker executor for serial writes. Using
# ``loop.run_in_executor`` against this avoids the per-call # ``loop.run_in_executor`` against this avoids the per-call
@@ -74,7 +74,7 @@ class AdalightClient(LEDClient):
# that ``asyncio.to_thread`` incurs (~510 µs per call), and # that ``asyncio.to_thread`` incurs (~510 µs per call), and
# guarantees FIFO ordering of writes from this client even when # guarantees FIFO ordering of writes from this client even when
# other tasks are using the default executor. # other tasks are using the default executor.
self._tx_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None self._tx_executor: concurrent.futures.ThreadPoolExecutor | None = None
async def connect(self) -> bool: async def connect(self) -> bool:
"""Open serial port and wait for Arduino reset.""" """Open serial port and wait for Arduino reset."""
@@ -245,7 +245,7 @@ class AdalightClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""Check if the serial port exists without opening it. """Check if the serial port exists without opening it.
@@ -10,7 +10,7 @@ so ``BLEClient`` can treat both backends identically.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from typing import List, Optional from typing import List
from ledgrab.core.devices.ble_transport import DiscoveredBLEDevice from ledgrab.core.devices.ble_transport import DiscoveredBLEDevice
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
@@ -49,7 +49,7 @@ async def android_ble_scan(timeout: float = 4.0) -> List[DiscoveredBLEDevice]:
continue continue
address, name, rssi_str = parts address, name, rssi_str = parts
try: try:
rssi: Optional[int] = int(rssi_str) rssi: int | None = int(rssi_str)
except ValueError: except ValueError:
rssi = None rssi = None
devices.append(DiscoveredBLEDevice(address=address, name=name or address, rssi=rssi)) devices.append(DiscoveredBLEDevice(address=address, name=name or address, rssi=rssi))
@@ -80,7 +80,7 @@ class AndroidBLETransport:
self._address = address self._address = address
self._write_char_uuid = write_char_uuid self._write_char_uuid = write_char_uuid
self._write_with_response = write_with_response self._write_with_response = write_with_response
self._handle: Optional[int] = None self._handle: int | None = None
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
@property @property
@@ -120,7 +120,7 @@ class AndroidBLETransport:
except Exception as exc: except Exception as exc:
logger.warning("Android BLE disconnect of %s raised: %s", self._address, exc) logger.warning("Android BLE disconnect of %s raised: %s", self._address, exc)
async def write(self, data: bytes, char_uuid: Optional[str] = None) -> None: async def write(self, data: bytes, char_uuid: str | None = None) -> None:
"""Write bytes to a characteristic on the connected peripheral. """Write bytes to a characteristic on the connected peripheral.
Serialised through an internal lock — BLE stacks do not tolerate Serialised through an internal lock — BLE stacks do not tolerate
@@ -8,7 +8,7 @@ optional ``@baud`` suffix).
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Optional, Tuple from typing import List, Tuple
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android from ledgrab.utils.platform import is_android
@@ -38,7 +38,7 @@ def _bridge():
class _UsbAddress: class _UsbAddress:
vendor_id: int vendor_id: int
product_id: int product_id: int
serial: Optional[str] serial: str | None
@classmethod @classmethod
def parse(cls, device: str) -> "_UsbAddress": def parse(cls, device: str) -> "_UsbAddress":
@@ -59,7 +59,7 @@ class _UsbAddress:
return cls(vid, pid, serial) return cls(vid, pid, serial)
def _format_url(vid: int, pid: int, serial: Optional[str]) -> str: def _format_url(vid: int, pid: int, serial: str | None) -> str:
base = f"usb:{vid:04x}:{pid:04x}" base = f"usb:{vid:04x}:{pid:04x}"
return f"{base}:{serial}" if serial else base return f"{base}:{serial}" if serial else base
@@ -101,7 +101,7 @@ class AndroidSerialTransport:
self._url = device self._url = device
self._addr = _UsbAddress.parse(device) self._addr = _UsbAddress.parse(device)
self._baud_rate = baud_rate self._baud_rate = baud_rate
self._handle: Optional[int] = None # opaque token from the bridge self._handle: int | None = None # opaque token from the bridge
@property @property
def is_open(self) -> bool: def is_open(self) -> bool:
@@ -17,7 +17,7 @@ from __future__ import annotations
import asyncio import asyncio
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union from typing import List, Tuple
import numpy as np import numpy as np
@@ -92,7 +92,7 @@ class BLEClient(LEDClient):
write_with_response=self._protocol.write_with_response, write_with_response=self._protocol.write_with_response,
) )
# AES key for Govee encrypted firmware — 16 raw bytes or None. # AES key for Govee encrypted firmware — 16 raw bytes or None.
self._aes_key: Optional[bytes] = None self._aes_key: bytes | None = None
if ble_govee_key and ble_family == "govee": if ble_govee_key and ble_family == "govee":
try: try:
import binascii import binascii
@@ -104,7 +104,7 @@ class BLEClient(LEDClient):
except Exception as exc: except Exception as exc:
logger.warning("Invalid Govee AES key — ignoring: %s", exc) logger.warning("Invalid Govee AES key — ignoring: %s", exc)
self._last_write_at: float = 0.0 self._last_write_at: float = 0.0
self._last_color: Optional[Tuple[int, int, int, int]] = None self._last_color: Tuple[int, int, int, int] | None = None
self._connected = False self._connected = False
# Throttle "not connected" warnings so the send loop doesn't spam logs # Throttle "not connected" warnings so the send loop doesn't spam logs
# at frame rate when a BLE connection drops silently. # at frame rate when a BLE connection drops silently.
@@ -161,12 +161,12 @@ class BLEClient(LEDClient):
return self._connected and self._transport.is_connected return self._connected and self._transport.is_connected
@property @property
def device_led_count(self) -> Optional[int]: def device_led_count(self) -> int | None:
return self._led_count or None return self._led_count or None
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
"""Average the strip to one color and write it — BLE protocols are whole-strip only.""" """Average the strip to one color and write it — BLE protocols are whole-strip only."""
@@ -249,7 +249,7 @@ class BLEClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, # noqa: ARG003 — unused; kept for the LEDClient contract http_client, # noqa: ARG003 — unused; kept for the LEDClient contract
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""BLE health isn't a passive check — a full GATT connect is the only signal. """BLE health isn't a passive check — a full GATT connect is the only signal.
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, List, Optional, Tuple from typing import TYPE_CHECKING, List, Tuple
from ledgrab.core.devices.ble_client import BLEClient, _strip_ble_scheme from ledgrab.core.devices.ble_client import BLEClient, _strip_ble_scheme
from ledgrab.core.devices.ble_protocols import ( from ledgrab.core.devices.ble_protocols import (
@@ -165,7 +165,7 @@ class BLEDeviceProvider(LEDDeviceProvider):
] ]
def get_ble_provider() -> Optional["BLEDeviceProvider"]: def get_ble_provider() -> "BLEDeviceProvider" | None:
"""Return the registered BLE provider, or ``None`` if not registered.""" """Return the registered BLE provider, or ``None`` if not registered."""
from ledgrab.core.devices.led_client import get_provider from ledgrab.core.devices.led_client import get_provider
@@ -18,7 +18,7 @@ from __future__ import annotations
import asyncio import asyncio
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Optional from typing import List
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android from ledgrab.utils.platform import is_android
@@ -65,7 +65,7 @@ class DiscoveredBLEDevice:
address: str address: str
name: str name: str
rssi: Optional[int] rssi: int | None
service_uuids: tuple = () service_uuids: tuple = ()
@@ -234,7 +234,7 @@ class BLETransport:
except Exception as exc: except Exception as exc:
logger.warning("BLE disconnect of %s raised: %s", self._address, exc) logger.warning("BLE disconnect of %s raised: %s", self._address, exc)
async def write(self, data: bytes, char_uuid: Optional[str] = None) -> None: async def write(self, data: bytes, char_uuid: str | None = None) -> None:
"""Send bytes to a GATT write characteristic. """Send bytes to a GATT write characteristic.
Serialised through an internal lock — BLE stacks do not like Serialised through an internal lock — BLE stacks do not like
@@ -2,7 +2,7 @@
import asyncio import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union from typing import List, Tuple
import numpy as np import numpy as np
@@ -48,9 +48,9 @@ class ChromaClient(LEDClient):
self._base_url = url or CHROMA_SDK_URL self._base_url = url or CHROMA_SDK_URL
self._led_count = led_count self._led_count = led_count
self._chroma_device_type = chroma_device_type self._chroma_device_type = chroma_device_type
self._session_url: Optional[str] = None self._session_url: str | None = None
self._connected = False self._connected = False
self._heartbeat_task: Optional[asyncio.Task] = None self._heartbeat_task: asyncio.Task | None = None
self._http_client = None self._http_client = None
async def connect(self) -> bool: async def connect(self) -> bool:
@@ -135,7 +135,7 @@ class ChromaClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
if not self.is_connected or not self._http_client: if not self.is_connected or not self._http_client:
@@ -204,7 +204,7 @@ class ChromaClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""Check if Chroma SDK is running.""" """Check if Chroma SDK is running."""
base = url or CHROMA_SDK_URL base = url or CHROMA_SDK_URL
@@ -3,7 +3,7 @@
import asyncio import asyncio
import struct import struct
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Tuple
import numpy as np import numpy as np
@@ -58,12 +58,12 @@ class DDPClient:
self._sequence = 0 self._sequence = 0
self._buses: List[BusConfig] = [] self._buses: List[BusConfig] = []
# Pre-allocated RGBW buffer (resized on demand) # Pre-allocated RGBW buffer (resized on demand)
self._rgbw_buf: Optional[np.ndarray] = None self._rgbw_buf: np.ndarray | None = None
self._rgbw_buf_n: int = 0 self._rgbw_buf_n: int = 0
# Pre-allocated send buffer (header + payload). Sized lazily on first # Pre-allocated send buffer (header + payload). Sized lazily on first
# send so we never allocate fresh bytes per frame on the hot path. # send so we never allocate fresh bytes per frame on the hot path.
self._send_buf: Optional[bytearray] = None self._send_buf: bytearray | None = None
self._send_view: Optional[memoryview] = None self._send_view: memoryview | None = None
async def connect(self): async def connect(self):
"""Establish UDP connection.""" """Establish UDP connection."""
@@ -13,7 +13,7 @@ from __future__ import annotations
import asyncio import asyncio
import socket import socket
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union from typing import List, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
import numpy as np import numpy as np
@@ -69,7 +69,7 @@ class DDPLEDClient(LEDClient):
led_count: int = 0, led_count: int = 0,
*, *,
rgbw: bool = False, rgbw: bool = False,
port: Optional[int] = None, port: int | None = None,
destination_id: int = DEFAULT_DESTINATION_ID, destination_id: int = DEFAULT_DESTINATION_ID,
color_order: int = DEFAULT_COLOR_ORDER, color_order: int = DEFAULT_COLOR_ORDER,
): ):
@@ -80,7 +80,7 @@ class DDPLEDClient(LEDClient):
self._rgbw = rgbw self._rgbw = rgbw
self._destination_id = destination_id & 0xFF self._destination_id = destination_id & 0xFF
self._color_order = color_order self._color_order = color_order
self._ddp: Optional[DDPClient] = None self._ddp: DDPClient | None = None
self._connected = False self._connected = False
@property @property
@@ -92,7 +92,7 @@ class DDPLEDClient(LEDClient):
return self._port return self._port
@property @property
def device_led_count(self) -> Optional[int]: def device_led_count(self) -> int | None:
return self._led_count or None return self._led_count or None
@property @property
@@ -151,7 +151,7 @@ class DDPLEDClient(LEDClient):
# uint16 scratch avoids overflow; integer divide keeps everything in uint8. # uint16 scratch avoids overflow; integer divide keeps everything in uint8.
return ((pixels.astype(np.uint16) * brightness) // 255).astype(np.uint8) return ((pixels.astype(np.uint16) * brightness) // 255).astype(np.uint8)
def _as_numpy(self, pixels: Union[List[Tuple[int, int, int]], np.ndarray]) -> np.ndarray: def _as_numpy(self, pixels: List[Tuple[int, int, int]] | np.ndarray) -> np.ndarray:
if isinstance(pixels, np.ndarray): if isinstance(pixels, np.ndarray):
arr = pixels arr = pixels
else: else:
@@ -164,7 +164,7 @@ class DDPLEDClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
if not self.is_connected: if not self.is_connected:
@@ -176,7 +176,7 @@ class DDPLEDClient(LEDClient):
def send_pixels_fast( def send_pixels_fast(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> None: ) -> None:
if not self.is_connected or self._ddp is None: if not self.is_connected or self._ddp is None:
@@ -189,7 +189,7 @@ class DDPLEDClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""DDP is connectionless UDP — health = host resolves + port reachable. """DDP is connectionless UDP — health = host resolves + port reachable.
@@ -5,7 +5,7 @@ that maps the flat Device storage model to the right typed config.
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List, Literal, Optional, Union from typing import List, Literal
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -43,13 +43,13 @@ class DDPConfig(BaseDeviceConfig):
@dataclass(frozen=True) @dataclass(frozen=True)
class AdalightConfig(BaseDeviceConfig): class AdalightConfig(BaseDeviceConfig):
device_type: Literal["adalight"] = "adalight" device_type: Literal["adalight"] = "adalight"
baud_rate: Optional[int] = None baud_rate: int | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
class AmbiLEDConfig(BaseDeviceConfig): class AmbiLEDConfig(BaseDeviceConfig):
device_type: Literal["ambiled"] = "ambiled" device_type: Literal["ambiled"] = "ambiled"
baud_rate: Optional[int] = None baud_rate: int | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -63,7 +63,7 @@ class DMXConfig(BaseDeviceConfig):
@dataclass(frozen=True) @dataclass(frozen=True)
class ESPNowConfig(BaseDeviceConfig): class ESPNowConfig(BaseDeviceConfig):
device_type: Literal["espnow"] = "espnow" device_type: Literal["espnow"] = "espnow"
baud_rate: Optional[int] = None baud_rate: int | None = None
espnow_peer_mac: str = "" espnow_peer_mac: str = ""
espnow_channel: int = 1 espnow_channel: int = 1
@@ -217,29 +217,29 @@ class USBHIDConfig(BaseDeviceConfig):
device_type: Literal["usbhid"] = "usbhid" device_type: Literal["usbhid"] = "usbhid"
DeviceConfig = Union[ DeviceConfig = (
WLEDConfig, WLEDConfig
DDPConfig, | DDPConfig
YeelightConfig, | YeelightConfig
WiZConfig, | WiZConfig
LIFXConfig, | LIFXConfig
GoveeConfig, | GoveeConfig
OPCConfig, | OPCConfig
NanoleafConfig, | NanoleafConfig
AdalightConfig, | AdalightConfig
AmbiLEDConfig, | AmbiLEDConfig
DMXConfig, | DMXConfig
ESPNowConfig, | ESPNowConfig
HueConfig, | HueConfig
SPIConfig, | SPIConfig
ChromaConfig, | ChromaConfig
GameSenseConfig, | GameSenseConfig
BLEConfig, | BLEConfig
GroupConfig, | GroupConfig
MQTTConfig, | MQTTConfig
WSConfig, | WSConfig
USBHIDConfig, | USBHIDConfig
OpenRGBConfig, | OpenRGBConfig
MockConfig, | MockConfig
DemoConfig, | DemoConfig
] )
@@ -29,7 +29,7 @@ from __future__ import annotations
import asyncio import asyncio
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable, Dict, Optional from typing import TYPE_CHECKING, Callable, Dict
from zeroconf import ServiceStateChange from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
@@ -74,9 +74,9 @@ class DiscoveryWatcher:
self._device_store = device_store self._device_store = device_store
self._fire_event = fire_event self._fire_event = fire_event
self._aiozc: Optional[AsyncZeroconf] = None self._aiozc: AsyncZeroconf | None = None
self._browser: Optional[AsyncServiceBrowser] = None self._browser: AsyncServiceBrowser | None = None
self._serial_task: Optional[asyncio.Task] = None self._serial_task: asyncio.Task | None = None
self._running = False self._running = False
self._started_at: float = 0.0 self._started_at: float = 0.0
@@ -3,7 +3,7 @@
import asyncio import asyncio
import struct import struct
import uuid import uuid
from typing import List, Optional, Tuple, Union from typing import List, Tuple
import numpy as np import numpy as np
@@ -40,7 +40,7 @@ class DMXClient(LEDClient):
def __init__( def __init__(
self, self,
host: str, host: str,
port: Optional[int] = None, port: int | None = None,
led_count: int = 1, led_count: int = 1,
protocol: str = "artnet", protocol: str = "artnet",
start_universe: int = 0, start_universe: int = 0,
@@ -123,7 +123,7 @@ class DMXClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
if not self._transport: if not self._transport:
@@ -133,7 +133,7 @@ class DMXClient(LEDClient):
def send_pixels_fast( def send_pixels_fast(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> None: ) -> None:
if not self._transport: if not self._transport:
@@ -3,7 +3,7 @@
import asyncio import asyncio
import struct import struct
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union from typing import List, Tuple
import numpy as np import numpy as np
@@ -59,7 +59,7 @@ class ESPNowClient(LEDClient):
self, self,
url: str = "", url: str = "",
led_count: int = 0, led_count: int = 0,
baud_rate: Optional[int] = None, baud_rate: int | None = None,
espnow_peer_mac: str = "FF:FF:FF:FF:FF:FF", espnow_peer_mac: str = "FF:FF:FF:FF:FF:FF",
espnow_channel: int = 1, espnow_channel: int = 1,
**kwargs, **kwargs,
@@ -109,7 +109,7 @@ class ESPNowClient(LEDClient):
def send_pixels_fast( def send_pixels_fast(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> None: ) -> None:
if not self.is_connected: if not self.is_connected:
@@ -129,7 +129,7 @@ class ESPNowClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
if not self.is_connected: if not self.is_connected:
@@ -146,7 +146,7 @@ class ESPNowClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""Check if the serial port is available without opening it.""" """Check if the serial port is available without opening it."""
port, _baud = parse_serial_url(url) port, _baud = parse_serial_url(url)
@@ -4,7 +4,7 @@ import json
import os import os
import platform import platform
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union from typing import List, Tuple
import numpy as np import numpy as np
@@ -19,7 +19,7 @@ GAME_DISPLAY_NAME = "LedGrab"
EVENT_NAME = "PIXEL_DATA" EVENT_NAME = "PIXEL_DATA"
def _get_gamesense_address() -> Optional[str]: def _get_gamesense_address() -> str | None:
"""Discover the SteelSeries GameSense address from coreProps.json.""" """Discover the SteelSeries GameSense address from coreProps.json."""
if platform.system() == "Windows": if platform.system() == "Windows":
props_path = os.path.join( props_path = os.path.join(
@@ -77,7 +77,7 @@ class GameSenseClient(LEDClient):
self._gs_device_type = gamesense_device_type self._gs_device_type = gamesense_device_type
self._connected = False self._connected = False
self._http_client = None self._http_client = None
self._base_url: Optional[str] = None self._base_url: str | None = None
async def connect(self) -> bool: async def connect(self) -> bool:
import httpx import httpx
@@ -174,7 +174,7 @@ class GameSenseClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
if not self.is_connected or not self._http_client: if not self.is_connected or not self._http_client:
@@ -227,7 +227,7 @@ class GameSenseClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""Check if SteelSeries Engine is running.""" """Check if SteelSeries Engine is running."""
address = url.replace("gamesense://", "").strip() if url else None address = url.replace("gamesense://", "").strip() if url else None
@@ -21,7 +21,7 @@ import json
import socket import socket
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union from typing import List, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
import numpy as np import numpy as np
@@ -105,8 +105,8 @@ class GoveeClient(LEDClient):
self._port = GOVEE_CONTROL_PORT self._port = GOVEE_CONTROL_PORT
self._led_count = led_count self._led_count = led_count
self._min_interval_s = max(0.0, min_interval_s) self._min_interval_s = max(0.0, min_interval_s)
self._transport: Optional[asyncio.DatagramTransport] = None self._transport: asyncio.DatagramTransport | None = None
self._protocol: Optional[_GoveeProtocol] = None self._protocol: _GoveeProtocol | None = None
self._connected = False self._connected = False
self._next_tx_at: float = 0.0 self._next_tx_at: float = 0.0
@@ -123,7 +123,7 @@ class GoveeClient(LEDClient):
return self._connected and self._transport is not None return self._connected and self._transport is not None
@property @property
def device_led_count(self) -> Optional[int]: def device_led_count(self) -> int | None:
return self._led_count or None return self._led_count or None
async def connect(self) -> bool: async def connect(self) -> bool:
@@ -185,7 +185,7 @@ class GoveeClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
"""Average the strip → colorwc with the resulting RGB.""" """Average the strip → colorwc with the resulting RGB."""
@@ -206,7 +206,7 @@ class GoveeClient(LEDClient):
def send_pixels_fast( def send_pixels_fast(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> None: ) -> None:
"""Synchronous variant for the hot loop.""" """Synchronous variant for the hot loop."""
@@ -248,7 +248,7 @@ class GoveeClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""Send devStatus and wait briefly for a reply on port 4002. """Send devStatus and wait briefly for a reply on port 4002.
@@ -298,7 +298,7 @@ class GoveeClient(LEDClient):
# ============================================================================ # ============================================================================
def _parse_scan_reply(raw: bytes) -> Optional[dict]: def _parse_scan_reply(raw: bytes) -> dict | None:
"""Parse a Govee scan reply into a flat metadata dict. """Parse a Govee scan reply into a flat metadata dict.
Govee sends ``{"msg": {"cmd": "scan", "data": {"ip": ..., "device": ..., Govee sends ``{"msg": {"cmd": "scan", "data": {"ip": ..., "device": ...,
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from typing import TYPE_CHECKING, List, Optional, Tuple, Union from typing import TYPE_CHECKING, List, Tuple
import numpy as np import numpy as np
@@ -82,14 +82,14 @@ class GroupLEDClient(LEDClient):
return self._connected and all(c.is_connected for c, _ in self._children) return self._connected and all(c.is_connected for c, _ in self._children)
@property @property
def device_led_count(self) -> Optional[int]: def device_led_count(self) -> int | None:
if self._group_mode == "sequence": if self._group_mode == "sequence":
return self._total_led_count return self._total_led_count
return None # independent mode uses user-specified led_count return None # independent mode uses user-specified led_count
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
if not self._children: if not self._children:
@@ -150,7 +150,7 @@ class GroupLEDClient(LEDClient):
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
return all(r is True for r in results if not isinstance(r, Exception)) return all(r is True for r in results if not isinstance(r, Exception))
async def snapshot_device_state(self) -> Optional[dict]: async def snapshot_device_state(self) -> dict | None:
"""Snapshot all children's states.""" """Snapshot all children's states."""
states = {} states = {}
for i, (client, _) in enumerate(self._children): for i, (client, _) in enumerate(self._children):
@@ -159,7 +159,7 @@ class GroupLEDClient(LEDClient):
states[i] = state states[i] = state
return states if states else None return states if states else None
async def restore_device_state(self, state: Optional[dict]) -> None: async def restore_device_state(self, state: dict | None) -> None:
"""Restore all children's states.""" """Restore all children's states."""
if not state: if not state:
return return
@@ -4,7 +4,7 @@ import asyncio
import socket import socket
import struct import struct
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union from typing import List, Tuple
import numpy as np import numpy as np
@@ -79,7 +79,7 @@ class HueClient(LEDClient):
self._username = hue_username self._username = hue_username
self._client_key = hue_client_key self._client_key = hue_client_key
self._group_id = hue_entertainment_group_id self._group_id = hue_entertainment_group_id
self._sock: Optional[socket.socket] = None self._sock: socket.socket | None = None
self._connected = False self._connected = False
self._sequence = 0 self._sequence = 0
self._dtls_sock = None self._dtls_sock = None
@@ -173,7 +173,7 @@ class HueClient(LEDClient):
def send_pixels_fast( def send_pixels_fast(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> None: ) -> None:
if not self._connected: if not self._connected:
@@ -197,7 +197,7 @@ class HueClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
if not self._connected: if not self._connected:
@@ -210,7 +210,7 @@ class HueClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""Check if the Hue bridge is reachable.""" """Check if the Hue bridge is reachable."""
bridge_ip = url.replace("hue://", "").rstrip("/") bridge_ip = url.replace("hue://", "").rstrip("/")
+20 -20
View File
@@ -3,7 +3,7 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
import numpy as np import numpy as np
@@ -17,7 +17,7 @@ class ProviderDeps:
"""Runtime dependencies injected into every provider.create_client() call.""" """Runtime dependencies injected into every provider.create_client() call."""
device_store: Optional["DeviceStore"] = None device_store: Optional["DeviceStore"] = None
mqtt_manager: Optional[object] = None # MQTTManager (avoid circular import) mqtt_manager: object | None = None # MQTTManager (avoid circular import)
@dataclass @dataclass
@@ -25,16 +25,16 @@ class DeviceHealth:
"""Health check result for an LED device.""" """Health check result for an LED device."""
online: bool = False online: bool = False
latency_ms: Optional[float] = None latency_ms: float | None = None
last_checked: Optional[datetime] = None last_checked: datetime | None = None
# Device-reported metadata (populated by type-specific health check) # Device-reported metadata (populated by type-specific health check)
device_name: Optional[str] = None device_name: str | None = None
device_version: Optional[str] = None device_version: str | None = None
device_led_count: Optional[int] = None device_led_count: int | None = None
device_rgbw: Optional[bool] = None device_rgbw: bool | None = None
device_led_type: Optional[str] = None device_led_type: str | None = None
device_fps: Optional[int] = None device_fps: int | None = None
error: Optional[str] = None error: str | None = None
class PairingNotReady(Exception): class PairingNotReady(Exception):
@@ -55,12 +55,12 @@ class DiscoveredDevice:
device_type: str device_type: str
ip: str ip: str
mac: str mac: str
led_count: Optional[int] led_count: int | None
version: Optional[str] version: str | None
# Optional provider-specific detected protocol identifier (e.g. BLE family # Optional provider-specific detected protocol identifier (e.g. BLE family
# like "sp110e" / "triones" / "zengge" / "govee"). Surfaced so the UI can # like "sp110e" / "triones" / "zengge" / "govee"). Surfaced so the UI can
# preselect the right sub-type when the user adds a discovered device. # preselect the right sub-type when the user adds a discovered device.
ble_family: Optional[str] = None ble_family: str | None = None
class LEDClient(ABC): class LEDClient(ABC):
@@ -99,7 +99,7 @@ class LEDClient(ABC):
@abstractmethod @abstractmethod
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
"""Send pixel colors to the LED device (async). """Send pixel colors to the LED device (async).
@@ -127,11 +127,11 @@ class LEDClient(ABC):
raise NotImplementedError("send_pixels_fast not supported for this device type") raise NotImplementedError("send_pixels_fast not supported for this device type")
@property @property
def device_led_count(self) -> Optional[int]: def device_led_count(self) -> int | None:
"""Actual LED count discovered after connect(). None if not available.""" """Actual LED count discovered after connect(). None if not available."""
return None return None
async def snapshot_device_state(self) -> Optional[dict]: async def snapshot_device_state(self) -> dict | None:
"""Snapshot device state before streaming starts. """Snapshot device state before streaming starts.
Override in subclasses that need to save/restore state around streaming. Override in subclasses that need to save/restore state around streaming.
@@ -139,7 +139,7 @@ class LEDClient(ABC):
""" """
return None return None
async def restore_device_state(self, state: Optional[dict]) -> None: async def restore_device_state(self, state: dict | None) -> None:
"""Restore device state after streaming stops. """Restore device state after streaming stops.
Args: Args:
@@ -152,7 +152,7 @@ class LEDClient(ABC):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""Check device health without a full client connection. """Check device health without a full client connection.
@@ -309,7 +309,7 @@ async def check_device_health(
device_type: str, device_type: str,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""Factory: dispatch health check to the right provider.""" """Factory: dispatch health check to the right provider."""
return await get_provider(device_type).check_health(url, http_client, prev_health) return await get_provider(device_type).check_health(url, http_client, prev_health)
@@ -22,7 +22,7 @@ import socket
import struct import struct
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union from typing import List, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
import numpy as np import numpy as np
@@ -142,7 +142,7 @@ def _build_set_power_payload(on: bool, duration_ms: int = 0) -> bytes:
return struct.pack("<HI", 65535 if on else 0, duration_ms & 0xFFFFFFFF) return struct.pack("<HI", 65535 if on else 0, duration_ms & 0xFFFFFFFF)
def _parse_state_service_reply(raw: bytes) -> Optional[dict]: def _parse_state_service_reply(raw: bytes) -> dict | None:
"""Parse a LIFX StateService (discovery) reply. """Parse a LIFX StateService (discovery) reply.
Returns ``{"mac": "aabbccddeeff", "service": int, "port": int}`` or Returns ``{"mac": "aabbccddeeff", "service": int, "port": int}`` or
@@ -192,8 +192,8 @@ class LIFXClient(LEDClient):
self._port = port self._port = port
self._led_count = led_count self._led_count = led_count
self._min_interval_s = max(0.0, min_interval_s) self._min_interval_s = max(0.0, min_interval_s)
self._transport: Optional[asyncio.DatagramTransport] = None self._transport: asyncio.DatagramTransport | None = None
self._protocol: Optional[_LIFXProtocol] = None self._protocol: _LIFXProtocol | None = None
self._connected = False self._connected = False
self._next_tx_at: float = 0.0 self._next_tx_at: float = 0.0
self._sequence: int = 0 self._sequence: int = 0
@@ -211,7 +211,7 @@ class LIFXClient(LEDClient):
return self._connected and self._transport is not None return self._connected and self._transport is not None
@property @property
def device_led_count(self) -> Optional[int]: def device_led_count(self) -> int | None:
return self._led_count or None return self._led_count or None
async def connect(self) -> bool: async def connect(self) -> bool:
@@ -257,7 +257,7 @@ class LIFXClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
"""Average the strip → HSBK → SetColor.""" """Average the strip → HSBK → SetColor."""
@@ -279,7 +279,7 @@ class LIFXClient(LEDClient):
def send_pixels_fast( def send_pixels_fast(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> None: ) -> None:
"""Synchronous variant — same shape, runs on the hot loop.""" """Synchronous variant — same shape, runs on the hot loop."""
@@ -318,7 +318,7 @@ class LIFXClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""Send GetService and wait briefly for a StateService reply.""" """Send GetService and wait briefly for a StateService reply."""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -2,7 +2,7 @@
import asyncio import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union from typing import List, Tuple
import numpy as np import numpy as np
@@ -50,7 +50,7 @@ class MockClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
if not self._connected: if not self._connected:
@@ -64,7 +64,7 @@ class MockClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
return DeviceHealth( return DeviceHealth(
online=True, online=True,
@@ -6,7 +6,7 @@ an ``mqtt_source_id`` so different devices can talk to different brokers.
""" """
import json import json
from typing import List, Optional, Tuple, Union from typing import List, Tuple
import numpy as np import numpy as np
@@ -81,7 +81,7 @@ class MQTTLEDClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
if self._runtime is None: if self._runtime is None:
@@ -111,7 +111,7 @@ class MQTTLEDClient(LEDClient):
prev_health=None, prev_health=None,
*, *,
mqtt_manager=None, mqtt_manager=None,
mqtt_source_id: Optional[str] = None, mqtt_source_id: str | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -24,7 +24,7 @@ from __future__ import annotations
import asyncio import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union from typing import List, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
@@ -137,7 +137,7 @@ class NanoleafClient(LEDClient):
self._led_count = led_count self._led_count = led_count
self._min_interval_s = max(0.0, min_interval_s) self._min_interval_s = max(0.0, min_interval_s)
self._request_timeout_s = request_timeout_s self._request_timeout_s = request_timeout_s
self._http: Optional[httpx.AsyncClient] = None self._http: httpx.AsyncClient | None = None
self._connected = False self._connected = False
self._next_tx_at: float = 0.0 self._next_tx_at: float = 0.0
@@ -156,7 +156,7 @@ class NanoleafClient(LEDClient):
return self._connected and self._http is not None return self._connected and self._http is not None
@property @property
def device_led_count(self) -> Optional[int]: def device_led_count(self) -> int | None:
return self._led_count or None return self._led_count or None
def _state_url(self) -> str: def _state_url(self) -> str:
@@ -194,7 +194,7 @@ class NanoleafClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
"""Average the strip and PUT a single HSB state update.""" """Average the strip and PUT a single HSB state update."""
@@ -252,7 +252,7 @@ class NanoleafClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""GET ``/api/v1/<token>/info``. Without a token we can't authenticate, """GET ``/api/v1/<token>/info``. Without a token we can't authenticate,
so we fall back to GET ``/api/v1`` which returns 401 when the host is so we fall back to GET ``/api/v1`` which returns 401 when the host is
@@ -21,7 +21,7 @@ from __future__ import annotations
import asyncio import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union from typing import List, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
import numpy as np import numpy as np
@@ -82,8 +82,8 @@ class OPCClient(LEDClient):
self._led_count = led_count self._led_count = led_count
self._channel = channel & 0xFF self._channel = channel & 0xFF
self._connect_timeout_s = connect_timeout_s self._connect_timeout_s = connect_timeout_s
self._writer: Optional[asyncio.StreamWriter] = None self._writer: asyncio.StreamWriter | None = None
self._reader: Optional[asyncio.StreamReader] = None self._reader: asyncio.StreamReader | None = None
self._connected = False self._connected = False
@property @property
@@ -103,7 +103,7 @@ class OPCClient(LEDClient):
return self._connected and self._writer is not None return self._connected and self._writer is not None
@property @property
def device_led_count(self) -> Optional[int]: def device_led_count(self) -> int | None:
return self._led_count or None return self._led_count or None
async def connect(self) -> bool: async def connect(self) -> bool:
@@ -144,7 +144,7 @@ class OPCClient(LEDClient):
return np.zeros_like(pixels) return np.zeros_like(pixels)
return ((pixels.astype(np.uint16) * brightness) // 255).astype(np.uint8) return ((pixels.astype(np.uint16) * brightness) // 255).astype(np.uint8)
def _as_numpy(self, pixels: Union[List[Tuple[int, int, int]], np.ndarray]) -> np.ndarray: def _as_numpy(self, pixels: List[Tuple[int, int, int]] | np.ndarray) -> np.ndarray:
if isinstance(pixels, np.ndarray): if isinstance(pixels, np.ndarray):
arr = pixels arr = pixels
else: else:
@@ -159,7 +159,7 @@ class OPCClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
if not self.is_connected: if not self.is_connected:
@@ -175,7 +175,7 @@ class OPCClient(LEDClient):
def send_pixels_fast( def send_pixels_fast(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> None: ) -> None:
"""Synchronous hot-path write. Drain runs implicitly when the OS buffer """Synchronous hot-path write. Drain runs implicitly when the OS buffer
@@ -197,7 +197,7 @@ class OPCClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""Open a TCP connection and close it. OPC has no protocol-level """Open a TCP connection and close it. OPC has no protocol-level
ping; reachable TCP is the strongest signal we get.""" ping; reachable TCP is the strongest signal we get."""
@@ -5,7 +5,7 @@ import socket
import struct import struct
import threading import threading
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, List, Optional, Tuple, Union from typing import Any, List, Tuple
import numpy as np import numpy as np
@@ -24,7 +24,7 @@ def parse_openrgb_url(url: str) -> Tuple[str, int, int, List[str]]:
When *zone_names* is non-empty, only LEDs in those zones are addressed. When *zone_names* is non-empty, only LEDs in those zones are addressed.
Multiple zones are separated by ``+``. Multiple zones are separated by ``+``.
""" """
zones_str: Optional[str] = None zones_str: str | None = None
if url.startswith("openrgb://"): if url.startswith("openrgb://"):
url = url[len("openrgb://") :] url = url[len("openrgb://") :]
@@ -87,16 +87,16 @@ class OpenRGBLEDClient(LEDClient):
self._client: Any = None # openrgb.OpenRGBClient self._client: Any = None # openrgb.OpenRGBClient
self._device: Any = None # openrgb.Device self._device: Any = None # openrgb.Device
self._connected = False self._connected = False
self._device_name: Optional[str] = None self._device_name: str | None = None
self._device_led_count: Optional[int] = None self._device_led_count: int | None = None
# Background sender thread — decouples processing loop from blocking TCP writes # Background sender thread — decouples processing loop from blocking TCP writes
self._send_lock = threading.Lock() self._send_lock = threading.Lock()
self._send_event = threading.Event() self._send_event = threading.Event()
self._send_pending: Optional[Tuple[np.ndarray, int]] = None # (pixels, brightness) self._send_pending: Tuple[np.ndarray, int] | None = None # (pixels, brightness)
self._send_thread: Optional[threading.Thread] = None self._send_thread: threading.Thread | None = None
self._send_stop = threading.Event() self._send_stop = threading.Event()
self._last_sent_pixels: Optional[np.ndarray] = None self._last_sent_pixels: np.ndarray | None = None
async def connect(self) -> bool: async def connect(self) -> bool:
"""Connect to OpenRGB server and access the target device.""" """Connect to OpenRGB server and access the target device."""
@@ -207,12 +207,12 @@ class OpenRGBLEDClient(LEDClient):
return self._connected and self._client is not None return self._connected and self._client is not None
@property @property
def device_led_count(self) -> Optional[int]: def device_led_count(self) -> int | None:
return self._device_led_count return self._device_led_count
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
"""Send pixel colors to the OpenRGB device (async wrapper).""" """Send pixel colors to the OpenRGB device (async wrapper)."""
@@ -232,7 +232,7 @@ class OpenRGBLEDClient(LEDClient):
def send_pixels_fast( def send_pixels_fast(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> None: ) -> None:
"""Non-blocking fire-and-forget send for the processing hot loop. """Non-blocking fire-and-forget send for the processing hot loop.
@@ -377,7 +377,7 @@ class OpenRGBLEDClient(LEDClient):
finally: finally:
comms.lock.release() comms.lock.release()
async def snapshot_device_state(self) -> Optional[dict]: async def snapshot_device_state(self) -> dict | None:
"""Save the active mode index before streaming.""" """Save the active mode index before streaming."""
if self._device is None: if self._device is None:
return None return None
@@ -387,7 +387,7 @@ class OpenRGBLEDClient(LEDClient):
logger.warning(f"Could not snapshot OpenRGB device state: {e}") logger.warning(f"Could not snapshot OpenRGB device state: {e}")
return None return None
async def restore_device_state(self, state: Optional[dict]) -> None: async def restore_device_state(self, state: dict | None) -> None:
"""Restore the original mode after streaming stops.""" """Restore the original mode after streaming stops."""
if not state or self._device is None: if not state or self._device is None:
return return
@@ -404,7 +404,7 @@ class OpenRGBLEDClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""Check OpenRGB server reachability via raw TCP socket connect. """Check OpenRGB server reachability via raw TCP socket connect.
@@ -11,13 +11,13 @@ zones individually, so it doesn't reduce.
from __future__ import annotations from __future__ import annotations
from typing import List, Tuple, Union from typing import List, Tuple
import numpy as np import numpy as np
def average_color( def average_color(
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
) -> Tuple[int, int, int]: ) -> Tuple[int, int, int]:
"""Reduce an N-pixel strip to one average RGB triple. """Reduce an N-pixel strip to one average RGB triple.
@@ -21,7 +21,7 @@ desktop COM ports on desktop, USB devices on Android.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Optional, Protocol from typing import List, Protocol
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android from ledgrab.utils.platform import is_android
@@ -119,7 +119,7 @@ def port_exists(device: str) -> bool:
def open_transport( def open_transport(
url: str, url: str,
baud_rate: Optional[int] = None, baud_rate: int | None = None,
timeout: float = 1.0, timeout: float = 1.0,
) -> SerialTransport: ) -> SerialTransport:
"""Construct an unopened transport for ``url``. Caller invokes ``open()``.""" """Construct an unopened transport for ``url``. Caller invokes ``open()``."""
@@ -2,7 +2,7 @@
import asyncio import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union from typing import List, Tuple
import numpy as np import numpy as np
@@ -155,7 +155,7 @@ class SPIClient(LEDClient):
def send_pixels_fast( def send_pixels_fast(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> None: ) -> None:
if not self._connected: if not self._connected:
@@ -205,7 +205,7 @@ class SPIClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
if not self._connected: if not self._connected:
@@ -222,7 +222,7 @@ class SPIClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""Check if the SPI/GPIO device is accessible.""" """Check if the SPI/GPIO device is accessible."""
import platform import platform
@@ -2,7 +2,7 @@
import asyncio import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union from typing import List, Tuple
import numpy as np import numpy as np
@@ -90,7 +90,7 @@ class USBHIDClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
if not self.is_connected: if not self.is_connected:
@@ -144,7 +144,7 @@ class USBHIDClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""Check if the HID device is present.""" """Check if the HID device is present."""
try: try:
@@ -17,7 +17,7 @@ import json
import socket import socket
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union from typing import List, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
import numpy as np import numpy as np
@@ -82,8 +82,8 @@ class WiZClient(LEDClient):
self._port = port self._port = port
self._led_count = led_count self._led_count = led_count
self._min_interval_s = max(0.0, min_interval_s) self._min_interval_s = max(0.0, min_interval_s)
self._transport: Optional[asyncio.DatagramTransport] = None self._transport: asyncio.DatagramTransport | None = None
self._protocol: Optional[_WiZProtocol] = None self._protocol: _WiZProtocol | None = None
self._connected = False self._connected = False
self._next_tx_at: float = 0.0 self._next_tx_at: float = 0.0
@@ -100,7 +100,7 @@ class WiZClient(LEDClient):
return self._connected and self._transport is not None return self._connected and self._transport is not None
@property @property
def device_led_count(self) -> Optional[int]: def device_led_count(self) -> int | None:
return self._led_count or None return self._led_count or None
async def connect(self) -> bool: async def connect(self) -> bool:
@@ -137,7 +137,7 @@ class WiZClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
"""Average the pixel strip to one color and push ``setPilot``.""" """Average the pixel strip to one color and push ``setPilot``."""
@@ -161,7 +161,7 @@ class WiZClient(LEDClient):
def send_pixels_fast( def send_pixels_fast(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> None: ) -> None:
"""Synchronous variant for the hot path. Same shape as send_pixels.""" """Synchronous variant for the hot path. Same shape as send_pixels."""
@@ -205,7 +205,7 @@ class WiZClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""Send a getPilot and wait briefly for any reply on a one-shot socket.""" """Send a getPilot and wait briefly for any reply on a one-shot socket."""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -4,7 +4,7 @@ import asyncio
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Tuple, Optional, Dict, Any from typing import List, Tuple, Dict, Any
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
@@ -106,10 +106,10 @@ class WLEDClient(LEDClient):
parsed = urlparse(self.url) parsed = urlparse(self.url)
self.host = parsed.hostname or parsed.netloc.split(":")[0] self.host = parsed.hostname or parsed.netloc.split(":")[0]
self._client: Optional[httpx.AsyncClient] = None self._client: httpx.AsyncClient | None = None
self._ddp_client: Optional[DDPClient] = None self._ddp_client: DDPClient | None = None
self._connected = False self._connected = False
self._pre_connect_state: Optional[dict] = None self._pre_connect_state: dict | None = None
async def connect(self) -> bool: async def connect(self) -> bool:
"""Establish connection to WLED device. """Establish connection to WLED device.
@@ -208,7 +208,7 @@ class WLEDClient(LEDClient):
self, self,
method: str, method: str,
endpoint: str, endpoint: str,
json_data: Optional[Dict[str, Any]] = None, json_data: Dict[str, Any] | None = None,
retry: bool = True, retry: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Make HTTP request to WLED device with retry logic. """Make HTTP request to WLED device with retry logic.
@@ -498,7 +498,7 @@ class WLEDClient(LEDClient):
# ===== LEDClient abstraction methods ===== # ===== LEDClient abstraction methods =====
async def snapshot_device_state(self) -> Optional[dict]: async def snapshot_device_state(self) -> dict | None:
"""Snapshot WLED state (on, lor, AudioReactive). """Snapshot WLED state (on, lor, AudioReactive).
If connect() already captured a pre-mutation snapshot, returns that If connect() already captured a pre-mutation snapshot, returns that
@@ -525,7 +525,7 @@ class WLEDClient(LEDClient):
logger.warning(f"Could not snapshot WLED state: {e}") logger.warning(f"Could not snapshot WLED state: {e}")
return None return None
async def restore_device_state(self, state: Optional[dict]) -> None: async def restore_device_state(self, state: dict | None) -> None:
"""Restore WLED state after streaming.""" """Restore WLED state after streaming."""
if not state: if not state:
return return
@@ -541,7 +541,7 @@ class WLEDClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""WLED health check via GET /json/info (+ /json/cfg for LED type).""" """WLED health check via GET /json/info (+ /json/cfg for LED type)."""
url = url.rstrip("/") url = url.rstrip("/")
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import time import time
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple from typing import TYPE_CHECKING, Dict, List, Tuple
import httpx import httpx
from zeroconf import ServiceStateChange from zeroconf import ServiceStateChange
@@ -47,7 +47,7 @@ class WLEDDeviceProvider(LEDDeviceProvider):
"""Provider for WLED LED controllers.""" """Provider for WLED LED controllers."""
def __init__(self): def __init__(self):
self._http_client: Optional[httpx.AsyncClient] = None self._http_client: httpx.AsyncClient | None = None
self._client_lock = asyncio.Lock() self._client_lock = asyncio.Lock()
# Per-base-URL state cache: base -> (expires_at, json_state_dict) # Per-base-URL state cache: base -> (expires_at, json_state_dict)
self._state_cache: Dict[str, tuple] = {} self._state_cache: Dict[str, tuple] = {}
@@ -186,7 +186,7 @@ class WLEDDeviceProvider(LEDDeviceProvider):
async def _enrich_device( async def _enrich_device(
self, url: str, fallback_name: str self, url: str, fallback_name: str
) -> tuple[str, Optional[str], Optional[int], str]: ) -> tuple[str, str | None, int | None, str]:
"""Probe a WLED device's /json/info to get name, version, LED count, MAC. """Probe a WLED device's /json/info to get name, version, LED count, MAC.
Reuses the shared HTTP client so discovery probes share connection-pool state. Reuses the shared HTTP client so discovery probes share connection-pool state.
+3 -3
View File
@@ -2,7 +2,7 @@
import asyncio import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple, Union from typing import Dict, List, Tuple
import numpy as np import numpy as np
@@ -85,7 +85,7 @@ class WSLEDClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
if not self._connected: if not self._connected:
@@ -123,7 +123,7 @@ class WSLEDClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
return DeviceHealth( return DeviceHealth(
online=True, online=True,
@@ -22,7 +22,7 @@ import json
import socket import socket
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union from typing import List, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
import numpy as np import numpy as np
@@ -77,8 +77,8 @@ class YeelightClient(LEDClient):
self._led_count = led_count self._led_count = led_count
self._min_interval_s = max(0.0, min_interval_s) self._min_interval_s = max(0.0, min_interval_s)
self._connect_timeout_s = connect_timeout_s self._connect_timeout_s = connect_timeout_s
self._reader: Optional[asyncio.StreamReader] = None self._reader: asyncio.StreamReader | None = None
self._writer: Optional[asyncio.StreamWriter] = None self._writer: asyncio.StreamWriter | None = None
self._connected = False self._connected = False
self._next_tx_at: float = 0.0 self._next_tx_at: float = 0.0
self._req_id: int = 0 self._req_id: int = 0
@@ -93,7 +93,7 @@ class YeelightClient(LEDClient):
return self._connected and self._writer is not None return self._connected and self._writer is not None
@property @property
def device_led_count(self) -> Optional[int]: def device_led_count(self) -> int | None:
return self._led_count or None return self._led_count or None
async def connect(self) -> bool: async def connect(self) -> bool:
@@ -141,7 +141,7 @@ class YeelightClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray], pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> bool:
"""Average the pixel strip to one color and ``set_rgb``. """Average the pixel strip to one color and ``set_rgb``.
@@ -185,7 +185,7 @@ class YeelightClient(LEDClient):
cls, cls,
url: str, url: str,
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: DeviceHealth | None = None,
) -> DeviceHealth: ) -> DeviceHealth:
"""Health check: open the TCP socket to the bulb and close it.""" """Health check: open the TCP socket to the bulb and close it."""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -230,7 +230,7 @@ _DISCOVER_REQUEST = (
).encode("ascii") ).encode("ascii")
def _parse_ssdp_response(raw: bytes) -> Optional[dict]: def _parse_ssdp_response(raw: bytes) -> dict | None:
"""Parse a Yeelight discovery response into a ``{header: value}`` dict. """Parse a Yeelight discovery response into a ``{header: value}`` dict.
Returns ``None`` when the payload doesn't look like a Yeelight reply Returns ``None`` when the payload doesn't look like a Yeelight reply
+2 -2
View File
@@ -1,6 +1,6 @@
"""Auto-crop postprocessing filter.""" """Auto-crop postprocessing filter."""
from typing import List, Optional from typing import List
import numpy as np import numpy as np
@@ -52,7 +52,7 @@ class AutoCropFilter(PostprocessingFilter):
), ),
] ]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: def process_image(self, image: np.ndarray, image_pool: ImagePool) -> np.ndarray | None:
threshold = self.options.get("threshold", 15) threshold = self.options.get("threshold", 15)
min_bar_size = self.options.get("min_bar_size", 20) min_bar_size = self.options.get("min_bar_size", 20)
min_aspect_ratio = float(self.options.get("min_aspect_ratio", 0.0)) min_aspect_ratio = float(self.options.get("min_aspect_ratio", 0.0))
+5 -5
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, List, Optional from typing import TYPE_CHECKING, Any, Dict, List
import numpy as np import numpy as np
@@ -23,8 +23,8 @@ class FilterOptionDef:
min_value: Any min_value: Any
max_value: Any max_value: Any
step: Any step: Any
choices: Optional[List[Dict[str, str]]] = None # for "select": [{value, label}] choices: List[Dict[str, str]] | None = None # for "select": [{value, label}]
max_length: Optional[int] = None # for "string" type max_length: int | None = None # for "string" type
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = { d = {
@@ -76,7 +76,7 @@ class PostprocessingFilter(ABC):
... ...
@abstractmethod @abstractmethod
def process_image(self, image: np.ndarray, image_pool: "ImagePool") -> Optional[np.ndarray]: def process_image(self, image: np.ndarray, image_pool: "ImagePool") -> np.ndarray | None:
"""Process image. """Process image.
Args: Args:
@@ -89,7 +89,7 @@ class PostprocessingFilter(ABC):
""" """
... ...
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]: def process_strip(self, strip: np.ndarray) -> np.ndarray | None:
"""Process a 1D LED strip array (N, 3) uint8. """Process a 1D LED strip array (N, 3) uint8.
Default implementation reshapes to (1, N, 3), calls process_image Default implementation reshapes to (1, N, 3), calls process_image
@@ -1,6 +1,6 @@
"""Brightness postprocessing filter.""" """Brightness postprocessing filter."""
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
import numpy as np import numpy as np
@@ -36,7 +36,7 @@ class BrightnessFilter(PostprocessingFilter):
), ),
] ]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: def process_image(self, image: np.ndarray, image_pool: ImagePool) -> np.ndarray | None:
if self.options["value"] == 1.0: if self.options["value"] == 1.0:
return None return None
image[:] = self._lut[image] image[:] = self._lut[image]
@@ -1,7 +1,7 @@
"""Color correction postprocessing filter.""" """Color correction postprocessing filter."""
import math import math
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
import numpy as np import numpy as np
@@ -118,7 +118,7 @@ class ColorCorrectionFilter(PostprocessingFilter):
), ),
] ]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: def process_image(self, image: np.ndarray, image_pool: ImagePool) -> np.ndarray | None:
if self._is_neutral: if self._is_neutral:
return None return None
image[:] = self._lut[image] image[:] = self._lut[image]
+2 -2
View File
@@ -1,6 +1,6 @@
"""Contrast postprocessing filter.""" """Contrast postprocessing filter."""
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
import numpy as np import numpy as np
@@ -42,7 +42,7 @@ class ContrastFilter(PostprocessingFilter):
), ),
] ]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: def process_image(self, image: np.ndarray, image_pool: ImagePool) -> np.ndarray | None:
if self.options["value"] == 1.0: if self.options["value"] == 1.0:
return None return None
image[:] = self._lut[image] image[:] = self._lut[image]
@@ -5,7 +5,7 @@ instantiated at runtime: the store expands it into the referenced
template's filters when building the processing pipeline. template's filters when building the processing pipeline.
""" """
from typing import List, Optional from typing import List
import numpy as np import numpy as np
@@ -37,10 +37,10 @@ class CSSFilterTemplateFilter(PostprocessingFilter):
), ),
] ]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: def process_image(self, image: np.ndarray, image_pool: ImagePool) -> np.ndarray | None:
# Never called — expanded at pipeline build time. # Never called — expanded at pipeline build time.
return None return None
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]: def process_strip(self, strip: np.ndarray) -> np.ndarray | None:
# Never called — expanded at pipeline build time. # Never called — expanded at pipeline build time.
return None return None
@@ -1,6 +1,6 @@
"""Downscaler postprocessing filter.""" """Downscaler postprocessing filter."""
from typing import List, Optional from typing import List
import numpy as np import numpy as np
@@ -32,7 +32,7 @@ class DownscalerFilter(PostprocessingFilter):
), ),
] ]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: def process_image(self, image: np.ndarray, image_pool: ImagePool) -> np.ndarray | None:
factor = self.options["factor"] factor = self.options["factor"]
if factor >= 1.0: if factor >= 1.0:
return None return None
@@ -5,7 +5,7 @@ instantiated at runtime: ``LiveStreamManager`` expands it into the
referenced template's filters when building the processing pipeline. referenced template's filters when building the processing pipeline.
""" """
from typing import List, Optional from typing import List
import numpy as np import numpy as np
@@ -37,6 +37,6 @@ class FilterTemplateFilter(PostprocessingFilter):
), ),
] ]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: def process_image(self, image: np.ndarray, image_pool: ImagePool) -> np.ndarray | None:
# Never called — expanded at pipeline build time by LiveStreamManager. # Never called — expanded at pipeline build time by LiveStreamManager.
return None return None
+2 -2
View File
@@ -1,6 +1,6 @@
"""Flip postprocessing filter.""" """Flip postprocessing filter."""
from typing import List, Optional from typing import List
import numpy as np import numpy as np
@@ -40,7 +40,7 @@ class FlipFilter(PostprocessingFilter):
), ),
] ]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: def process_image(self, image: np.ndarray, image_pool: ImagePool) -> np.ndarray | None:
h = self.options.get("horizontal", False) h = self.options.get("horizontal", False)
v = self.options.get("vertical", False) v = self.options.get("vertical", False)
if not h and not v: if not h and not v:
@@ -1,7 +1,7 @@
"""Frame interpolation postprocessing filter.""" """Frame interpolation postprocessing filter."""
import time import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
import numpy as np import numpy as np
@@ -38,21 +38,21 @@ class FrameInterpolationFilter(PostprocessingFilter):
def __init__(self, options: Dict[str, Any]): def __init__(self, options: Dict[str, Any]):
super().__init__(options) super().__init__(options)
self._frame_a: Optional[np.ndarray] = None # frame N-1 self._frame_a: np.ndarray | None = None # frame N-1
self._frame_b: Optional[np.ndarray] = None # frame N (latest source) self._frame_b: np.ndarray | None = None # frame N (latest source)
self._time_a: float = 0.0 self._time_a: float = 0.0
self._time_b: float = 0.0 self._time_b: float = 0.0
self._sig_b: Optional[bytes] = None # 64-byte signature of frame_b input self._sig_b: bytes | None = None # 64-byte signature of frame_b input
# Pre-allocated uint16 scratch buffers for blending # Pre-allocated uint16 scratch buffers for blending
self._u16_a: Optional[np.ndarray] = None self._u16_a: np.ndarray | None = None
self._u16_b: Optional[np.ndarray] = None self._u16_b: np.ndarray | None = None
self._blend_shape: Optional[tuple] = None self._blend_shape: tuple | None = None
@classmethod @classmethod
def get_options_schema(cls) -> List[FilterOptionDef]: def get_options_schema(cls) -> List[FilterOptionDef]:
return [] return []
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: def process_image(self, image: np.ndarray, image_pool: ImagePool) -> np.ndarray | None:
"""Return interpolated blend on idle ticks; update state on new source frames. """Return interpolated blend on idle ticks; update state on new source frames.
Returns: Returns:
@@ -61,11 +61,11 @@ class FrameInterpolationFilter(PostprocessingFilter):
""" """
return self._blend(image, lambda shape: image_pool.acquire(*shape)) return self._blend(image, lambda shape: image_pool.acquire(*shape))
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]: def process_strip(self, strip: np.ndarray) -> np.ndarray | None:
"""Frame interpolation for 1D LED strips — allocates directly.""" """Frame interpolation for 1D LED strips — allocates directly."""
return self._blend(strip, lambda shape: np.empty(shape, dtype=np.uint8)) return self._blend(strip, lambda shape: np.empty(shape, dtype=np.uint8))
def _blend(self, data: np.ndarray, alloc_fn) -> Optional[np.ndarray]: def _blend(self, data: np.ndarray, alloc_fn) -> np.ndarray | None:
"""Shared blend logic for both images and strips.""" """Shared blend logic for both images and strips."""
now = time.perf_counter() now = time.perf_counter()

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