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]
- 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:
- id: ruff
args: [--line-length=100, --target-version=py311]
+8
View File
@@ -117,3 +117,11 @@ target-version = ['py311']
[tool.ruff]
line-length = 100
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 os
import threading
from typing import Any, Optional
from typing import Any
_server_thread: Optional[threading.Thread] = None
_server: Optional[Any] = None # uvicorn.Server
_loop: Optional[asyncio.AbstractEventLoop] = None
_server_thread: threading.Thread | None = None
_server: Any | None = None # uvicorn.Server
_loop: asyncio.AbstractEventLoop | None = None
def start_server(data_dir: str, port: int = 8080) -> None:
@@ -3,7 +3,7 @@
import asyncio
import threading
import time
from typing import Callable, Optional
from typing import Callable
import numpy as np
from starlette.websockets import WebSocket
@@ -61,8 +61,8 @@ async def stream_capture_test(
websocket: WebSocket,
engine_factory: Callable,
duration: float,
pp_filters: Optional[list] = None,
preview_width: Optional[int] = None,
pp_filters: list | None = None,
preview_width: int | None = None,
) -> None:
"""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."""
import asyncio
from typing import Annotated, Optional
from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException, Query
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"])
async def list_audio_sources(
_auth: AuthRequired,
source_type: Optional[str] = Query(
source_type: str | None = Query(
None, description="Filter by source_type: capture or processed"
),
store: AudioSourceStore = Depends(get_audio_source_store),
@@ -1,7 +1,7 @@
"""Output target routes: CRUD endpoints and batch state/metrics queries."""
import asyncio
from typing import Annotated, Optional
from typing import Annotated
from fastapi import APIRouter, Body, HTTPException, Depends
@@ -421,7 +421,7 @@ async def get_target(
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:
if payload_id is not None:
return payload_id
+1 -2
View File
@@ -9,7 +9,6 @@ import subprocess
import sys
import time
from datetime import datetime, timezone
from typing import Optional
import os
@@ -190,7 +189,7 @@ async def list_all_tags(_: AuthRequired):
@router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"])
async def get_displays(
_: 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.
@@ -1,7 +1,7 @@
"""Value source routes: CRUD for value sources."""
import asyncio
from typing import Annotated, Optional
from typing import Annotated
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"])
async def list_value_sources(
_auth: AuthRequired,
source_type: Optional[str] = Query(
source_type: str | None = Query(
None,
description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene",
),
+9 -9
View File
@@ -1,7 +1,7 @@
"""Asset schemas (CRUD)."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -9,15 +9,15 @@ from pydantic import BaseModel, Field
class AssetUpdate(BaseModel):
"""Request to update asset metadata."""
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Display name")
description: Optional[str] = Field(None, max_length=500, description="Optional description")
tags: Optional[List[str]] = Field(None, description="User-defined tags")
icon: Optional[str] = Field(
name: str | None = Field(None, min_length=1, max_length=100, description="Display name")
description: str | None = Field(None, max_length=500, description="Optional description")
tags: List[str] | None = Field(None, description="User-defined tags")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
asset_type: str = Field(description="Asset type: sound, image, video, other")
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")
prebuilt: bool = Field(False, description="Whether this is a shipped prebuilt asset")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,7 +1,7 @@
"""Audio processing template schemas."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class AudioProcessingTemplateCreate(BaseModel):
filters: List[FilterInstanceSchema] = Field(
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")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class AudioProcessingTemplateCreate(BaseModel):
class AudioProcessingTemplateUpdate(BaseModel):
"""Request to update an audio processing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
filters: List[FilterInstanceSchema] | None = Field(
None, description="Ordered list of audio filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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."""
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
@@ -15,16 +15,16 @@ class _AudioSourceResponseBase(BaseModel):
id: str = Field(description="Source ID")
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")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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"
device_index: int = Field(description="Audio device index (-1 = default)")
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):
@@ -45,10 +45,8 @@ class ProcessedAudioSourceResponse(_AudioSourceResponseBase):
AudioSourceResponse = Annotated[
Union[
Annotated[CaptureAudioSourceResponse, Tag("capture")],
Annotated[ProcessedAudioSourceResponse, Tag("processed")],
],
Annotated[CaptureAudioSourceResponse, Tag("capture")]
| Annotated[ProcessedAudioSourceResponse, Tag("processed")],
Discriminator("source_type"),
]
@@ -61,14 +59,14 @@ class _AudioSourceCreateBase(BaseModel):
"""Shared fields for all audio source create requests."""
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")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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"
device_index: int = Field(-1, description="Audio device index (-1 = default)")
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):
@@ -89,10 +87,8 @@ class ProcessedAudioSourceCreate(_AudioSourceCreateBase):
AudioSourceCreate = Annotated[
Union[
Annotated[CaptureAudioSourceCreate, Tag("capture")],
Annotated[ProcessedAudioSourceCreate, Tag("processed")],
],
Annotated[CaptureAudioSourceCreate, Tag("capture")]
| Annotated[ProcessedAudioSourceCreate, Tag("processed")],
Discriminator("source_type"),
]
@@ -104,15 +100,15 @@ AudioSourceCreate = Annotated[
class _AudioSourceUpdateBase(BaseModel):
"""Shared fields for all audio source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -121,24 +117,22 @@ class _AudioSourceUpdateBase(BaseModel):
class CaptureAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["capture"] = "capture"
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
device_index: int | None = Field(None, description="Audio device index (-1 = default)")
is_loopback: bool | None = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: str | None = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["processed"] = "processed"
audio_source_id: Optional[str] = Field(None, description="Input audio source ID")
audio_processing_template_id: Optional[str] = Field(
audio_source_id: str | None = Field(None, description="Input audio source ID")
audio_processing_template_id: str | None = Field(
None, description="Audio processing template ID"
)
AudioSourceUpdate = Annotated[
Union[
Annotated[CaptureAudioSourceUpdate, Tag("capture")],
Annotated[ProcessedAudioSourceUpdate, Tag("processed")],
],
Annotated[CaptureAudioSourceUpdate, Tag("capture")]
| Annotated[ProcessedAudioSourceUpdate, Tag("processed")],
Discriminator("source_type"),
]
@@ -1,7 +1,7 @@
"""Audio capture template and engine schemas."""
from datetime import datetime
from typing import Dict, List, Optional
from typing import Dict, List
from pydantic import BaseModel, Field
@@ -14,14 +14,14 @@ class AudioTemplateCreate(BaseModel):
description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1
)
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -31,17 +31,17 @@ class AudioTemplateCreate(BaseModel):
class AudioTemplateUpdate(BaseModel):
"""Request to update an audio template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: Optional[str] = Field(None, description="Audio engine type")
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: str | None = Field(None, description="Audio engine type")
engine_config: Dict | None = Field(None, description="Engine-specific configuration")
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -11,57 +11,53 @@ class RuleSchema(BaseModel):
rule_type: str = Field(description="Rule type discriminator (e.g. 'application')")
# Application rule fields
apps: Optional[List[str]] = Field(None, description="Process names (for application rule)")
match_type: Optional[str] = Field(
apps: List[str] | None = Field(None, description="Process names (for application rule)")
match_type: str | None = Field(
None, description="'running' or 'topmost' (for application rule)"
)
# Time-of-day rule fields
start_time: Optional[str] = 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)")
start_time: str | None = Field(None, description="Start 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
idle_minutes: Optional[int] = Field(
idle_minutes: int | None = Field(
None, description="Idle timeout in minutes (for system_idle rule)"
)
when_idle: Optional[bool] = Field(
None, description="True=active when idle (for system_idle rule)"
)
when_idle: bool | None = Field(None, description="True=active when idle (for system_idle rule)")
# 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_source_id: Optional[str] = Field(None, description="MQTT source ID (for mqtt rule)")
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt rule)")
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt rule)")
match_mode: Optional[str] = Field(
mqtt_source_id: str | None = Field(None, description="MQTT source ID (for mqtt rule)")
topic: str | None = Field(None, description="MQTT topic to watch (for mqtt rule)")
payload: str | None = Field(None, description="Expected payload value (for mqtt rule)")
match_mode: str | None = Field(
None, description="'exact', 'contains', or 'regex' (for mqtt rule)"
)
# Webhook rule fields
token: Optional[str] = Field(
None, description="Secret token for webhook URL (for webhook rule)"
)
token: str | None = Field(None, description="Secret token for webhook URL (for webhook rule)")
# 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)"
)
entity_id: Optional[str] = Field(
entity_id: str | None = Field(
None,
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)",
)
# HTTP poll rule fields
value_source_id: Optional[str] = Field(
value_source_id: str | None = Field(
None,
description=(
"Value source ID (for http_poll rule). The referenced "
"ValueSource must be of source_type='http'."
),
)
operator: Optional[str] = Field(
operator: str | None = Field(
None,
description=(
"Comparison operator for http_poll rule: "
"'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')"
)
@@ -77,20 +73,20 @@ class AutomationCreate(BaseModel):
enabled: bool = Field(default=True, description="Whether the automation is enabled")
rule_logic: str = Field(default="or", description="How rules combine: 'or' or 'and'")
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(
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"
)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -100,24 +96,22 @@ class AutomationCreate(BaseModel):
class AutomationUpdate(BaseModel):
"""Request to update an automation."""
name: Optional[str] = Field(None, description="Automation name", min_length=1, max_length=100)
enabled: Optional[bool] = Field(None, description="Whether the automation is enabled")
rule_logic: Optional[str] = Field(None, description="How rules combine: 'or' or 'and'")
rules: Optional[List[RuleSchema]] = Field(None, description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: Optional[str] = Field(
None, description="'none', 'revert', or 'fallback_scene'"
)
deactivation_scene_preset_id: Optional[str] = Field(
name: str | None = Field(None, description="Automation name", min_length=1, max_length=100)
enabled: bool | None = Field(None, description="Whether the automation is enabled")
rule_logic: str | None = Field(None, description="How rules combine: 'or' or 'and'")
rules: List[RuleSchema] | None = Field(None, description="List of rules")
scene_preset_id: str | None = Field(None, description="Scene preset to activate")
deactivation_mode: str | None = Field(None, description="'none', 'revert', or 'fallback_scene'")
deactivation_scene_preset_id: str | None = Field(
None, description="Scene preset for fallback deactivation"
)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
rule_logic: str = Field(description="Rule combination logic")
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_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")
webhook_url: Optional[str] = Field(
webhook_url: str | None = Field(
None, description="Webhook URL for the first webhook rule (if any)"
)
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"
)
last_deactivated_at: Optional[datetime] = Field(
last_deactivated_at: datetime | None = Field(
None, description="Last time this automation was deactivated"
)
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,7 +1,7 @@
"""Color strip processing template schemas."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class ColorStripProcessingTemplateCreate(BaseModel):
filters: List[FilterInstanceSchema] = Field(
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")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class ColorStripProcessingTemplateCreate(BaseModel):
class ColorStripProcessingTemplateUpdate(BaseModel):
"""Request to update a color strip processing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
filters: List[FilterInstanceSchema] | None = Field(
None, description="Ordered list of filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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."""
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 ledgrab.api.schemas.devices import Calibration
# =====================================================================
# Helper models (unchanged)
# =====================================================================
@@ -16,10 +15,10 @@ from ledgrab.api.schemas.devices import Calibration
class AppSoundOverride(BaseModel):
"""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)"
)
volume: Optional[float] = Field(
volume: float | None = Field(
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
)
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,
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")
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"
)
processing_template_id: Optional[str] = Field(
processing_template_id: str | None = Field(
None, description="Optional color strip processing template ID"
)
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")
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)")
overlay_active: bool = Field(
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")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
smoothing: Any = Field(description="Temporal smoothing")
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):
source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(description="Temporal smoothing")
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):
source_type: Literal["single_color"] = "single_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):
source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
stops: List[ColorStop] | None = Field(None, description="Color stops")
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
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):
source_type: Literal["effect"] = "effect"
effect_type: str = Field(description="Effect algorithm")
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")
intensity: Any = Field(description="Effect intensity")
scale: Any = Field(description="Spatial scale")
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):
@@ -165,7 +164,7 @@ class AudioCSSResponse(_CSSResponseBase):
sensitivity: Any = Field(description="Audio sensitivity")
smoothing: Any = Field(description="Temporal smoothing")
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_peak: Any = Field(description="Peak color")
mirror: bool = Field(description="Mirror mode")
@@ -188,7 +187,7 @@ class NotificationCSSResponse(_CSSResponseBase):
app_filter_mode: str = Field(description="App filter mode")
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")
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")
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"
waves: List[dict] = Field(description="Wave layer definitions")
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):
@@ -248,25 +247,23 @@ class GameEventCSSResponse(_CSSResponseBase):
ColorStripSourceResponse = Annotated[
Union[
Annotated[PictureCSSResponse, Tag("picture")],
Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")],
Annotated[SingleColorCSSResponse, Tag("single_color")],
Annotated[GradientCSSResponse, Tag("gradient")],
Annotated[EffectCSSResponse, Tag("effect")],
Annotated[CompositeCSSResponse, Tag("composite")],
Annotated[MappedCSSResponse, Tag("mapped")],
Annotated[AudioCSSResponse, Tag("audio")],
Annotated[ApiInputCSSResponse, Tag("api_input")],
Annotated[NotificationCSSResponse, Tag("notification")],
Annotated[DaylightCSSResponse, Tag("daylight")],
Annotated[CandlelightCSSResponse, Tag("candlelight")],
Annotated[ProcessedCSSResponse, Tag("processed")],
Annotated[WeatherCSSResponse, Tag("weather")],
Annotated[KeyColorsCSSResponse, Tag("key_colors")],
Annotated[MathWaveCSSResponse, Tag("math_wave")],
Annotated[GameEventCSSResponse, Tag("game_event")],
],
Annotated[PictureCSSResponse, Tag("picture")]
| Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")]
| Annotated[SingleColorCSSResponse, Tag("single_color")]
| Annotated[GradientCSSResponse, Tag("gradient")]
| Annotated[EffectCSSResponse, Tag("effect")]
| Annotated[CompositeCSSResponse, Tag("composite")]
| Annotated[MappedCSSResponse, Tag("mapped")]
| Annotated[AudioCSSResponse, Tag("audio")]
| Annotated[ApiInputCSSResponse, Tag("api_input")]
| Annotated[NotificationCSSResponse, Tag("notification")]
| Annotated[DaylightCSSResponse, Tag("daylight")]
| Annotated[CandlelightCSSResponse, Tag("candlelight")]
| Annotated[ProcessedCSSResponse, Tag("processed")]
| Annotated[WeatherCSSResponse, Tag("weather")]
| Annotated[KeyColorsCSSResponse, Tag("key_colors")]
| Annotated[MathWaveCSSResponse, Tag("math_wave")]
| Annotated[GameEventCSSResponse, Tag("game_event")],
Discriminator("source_type"),
]
@@ -281,15 +278,15 @@ class _CSSCreateBase(BaseModel):
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)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
description: str | None = Field(None, description="Optional description", max_length=500)
clock_id: str | None = Field(None, description="Optional sync clock ID")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
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):
source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
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):
source_type: Literal["single_color"] = "single_color"
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):
source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
easing: Optional[str] = Field(None, description="Gradient easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
stops: List[ColorStop] | None = Field(None, description="Color stops")
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
easing: str | None = Field(None, description="Gradient easing")
gradient_id: str | None = Field(None, description="Gradient entity ID")
class EffectCSSCreate(_CSSCreateBase):
source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
effect_type: str | None = Field(None, description="Effect algorithm")
palette: str | None = Field(None, description="Named palette")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)")
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops")
mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
class CompositeCSSCreate(_CSSCreateBase):
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):
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):
source_type: Literal["audio"] = "audio"
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
visualization_mode: str | None = Field(None, description="Audio visualization mode")
audio_source_id: str | None = Field(None, description="Mono audio source ID")
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)")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
palette: str | None = Field(None, description="Named palette")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
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, 0.01-0.5)"
)
@@ -367,23 +364,23 @@ class ApiInputCSSCreate(_CSSCreateBase):
source_type: Literal["api_input"] = "api_input"
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)")
interpolation: Optional[str] = Field(None, description="LED count interpolation mode")
interpolation: str | None = Field(None, description="LED count interpolation mode")
class NotificationCSSCreate(_CSSCreateBase):
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")
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"
)
app_colors: Optional[Dict[str, str]] = Field(None, description="Per-app hex colors")
app_filter_mode: Optional[str] = Field(None, description="App filter mode")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
app_colors: Dict[str, str] | None = Field(None, description="Per-app hex colors")
app_filter_mode: str | None = Field(None, description="App filter mode")
app_filter_list: List[str] | None = Field(None, description="App names for filter")
os_listener: bool | None = Field(None, description="Listen for OS notifications")
sound_asset_id: str | None = Field(None, description="Global notification sound asset ID")
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"
)
@@ -391,9 +388,9 @@ class NotificationCSSCreate(_CSSCreateBase):
class DaylightCSSCreate(_CSSCreateBase):
source_type: Literal["daylight"] = "daylight"
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")
latitude: Optional[float] = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(
use_real_time: bool | None = Field(None, description="Use wall-clock time")
latitude: float | None = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: float | None = Field(
None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0
)
@@ -402,23 +399,23 @@ class CandlelightCSSCreate(_CSSCreateBase):
source_type: Literal["candlelight"] = "candlelight"
color: Any = Field(default=None, description="Candle color [R,G,B]")
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
)
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)")
candle_type: Optional[str] = Field(None, description="Candle type preset")
candle_type: str | None = Field(None, description="Candle type preset")
class ProcessedCSSCreate(_CSSCreateBase):
source_type: Literal["processed"] = "processed"
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Processing template ID")
input_source_id: str | None = Field(None, description="Input color strip source ID")
processing_template_id: str | None = Field(None, description="Processing template ID")
class WeatherCSSCreate(_CSSCreateBase):
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)")
temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)")
@@ -426,49 +423,47 @@ class WeatherCSSCreate(_CSSCreateBase):
class KeyColorsCSSCreate(_CSSCreateBase):
source_type: Literal["key_colors"] = "key_colors"
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")
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_value_source_id: Optional[str] = Field(
brightness_value_source_id: str | None = Field(
None, description="Dynamic brightness value source ID"
)
class MathWaveCSSCreate(_CSSCreateBase):
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)")
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):
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)")
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[
Union[
Annotated[PictureCSSCreate, Tag("picture")],
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")],
Annotated[SingleColorCSSCreate, Tag("single_color")],
Annotated[GradientCSSCreate, Tag("gradient")],
Annotated[EffectCSSCreate, Tag("effect")],
Annotated[CompositeCSSCreate, Tag("composite")],
Annotated[MappedCSSCreate, Tag("mapped")],
Annotated[AudioCSSCreate, Tag("audio")],
Annotated[ApiInputCSSCreate, Tag("api_input")],
Annotated[NotificationCSSCreate, Tag("notification")],
Annotated[DaylightCSSCreate, Tag("daylight")],
Annotated[CandlelightCSSCreate, Tag("candlelight")],
Annotated[ProcessedCSSCreate, Tag("processed")],
Annotated[WeatherCSSCreate, Tag("weather")],
Annotated[KeyColorsCSSCreate, Tag("key_colors")],
Annotated[MathWaveCSSCreate, Tag("math_wave")],
Annotated[GameEventCSSCreate, Tag("game_event")],
],
Annotated[PictureCSSCreate, Tag("picture")]
| Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")]
| Annotated[SingleColorCSSCreate, Tag("single_color")]
| Annotated[GradientCSSCreate, Tag("gradient")]
| Annotated[EffectCSSCreate, Tag("effect")]
| Annotated[CompositeCSSCreate, Tag("composite")]
| Annotated[MappedCSSCreate, Tag("mapped")]
| Annotated[AudioCSSCreate, Tag("audio")]
| Annotated[ApiInputCSSCreate, Tag("api_input")]
| Annotated[NotificationCSSCreate, Tag("notification")]
| Annotated[DaylightCSSCreate, Tag("daylight")]
| Annotated[CandlelightCSSCreate, Tag("candlelight")]
| Annotated[ProcessedCSSCreate, Tag("processed")]
| Annotated[WeatherCSSCreate, Tag("weather")]
| Annotated[KeyColorsCSSCreate, Tag("key_colors")]
| Annotated[MathWaveCSSCreate, Tag("math_wave")]
| Annotated[GameEventCSSCreate, Tag("game_event")],
Discriminator("source_type"),
]
@@ -481,17 +476,17 @@ ColorStripSourceCreate = Annotated[
class _CSSUpdateBase(BaseModel):
"""Shared fields for all color strip source update requests."""
name: Optional[str] = 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)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
led_count: int | None = Field(None, description="Total LED count (0 = auto)", ge=0)
description: str | None = Field(None, description="Optional description", max_length=500)
clock_id: str | None = Field(None, description="Optional sync clock ID")
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -500,66 +495,66 @@ class _CSSUpdateBase(BaseModel):
class PictureCSSUpdate(_CSSUpdateBase):
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)")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
interpolation_mode: str | None = Field(None, description="Interpolation mode")
calibration: Calibration | None = Field(None, description="LED calibration")
class PictureAdvancedCSSUpdate(_CSSUpdateBase):
source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
interpolation_mode: str | None = Field(None, description="Interpolation mode")
calibration: Calibration | None = Field(None, description="LED calibration")
class SingleColorCSSUpdate(_CSSUpdateBase):
source_type: Literal["single_color"] = "single_color"
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):
source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
easing: Optional[str] = Field(None, description="Gradient easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
stops: List[ColorStop] | None = Field(None, description="Color stops")
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
easing: str | None = Field(None, description="Gradient easing")
gradient_id: str | None = Field(None, description="Gradient entity ID")
class EffectCSSUpdate(_CSSUpdateBase):
source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
effect_type: str | None = Field(None, description="Effect algorithm")
palette: str | None = Field(None, description="Named palette")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)")
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops")
mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
class CompositeCSSUpdate(_CSSUpdateBase):
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):
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):
source_type: Literal["audio"] = "audio"
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
visualization_mode: str | None = Field(None, description="Audio visualization mode")
audio_source_id: str | None = Field(None, description="Mono audio source ID")
sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
palette: str | None = Field(None, description="Named palette")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
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)")
@@ -567,23 +562,23 @@ class ApiInputCSSUpdate(_CSSUpdateBase):
source_type: Literal["api_input"] = "api_input"
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)")
interpolation: Optional[str] = Field(None, description="LED count interpolation mode")
interpolation: str | None = Field(None, description="LED count interpolation mode")
class NotificationCSSUpdate(_CSSUpdateBase):
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")
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"
)
app_colors: Optional[Dict[str, str]] = Field(None, description="Per-app hex colors")
app_filter_mode: Optional[str] = Field(None, description="App filter mode")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
app_colors: Dict[str, str] | None = Field(None, description="Per-app hex colors")
app_filter_mode: str | None = Field(None, description="App filter mode")
app_filter_list: List[str] | None = Field(None, description="App names for filter")
os_listener: bool | None = Field(None, description="Listen for OS notifications")
sound_asset_id: str | None = Field(None, description="Global notification sound asset ID")
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"
)
@@ -591,9 +586,9 @@ class NotificationCSSUpdate(_CSSUpdateBase):
class DaylightCSSUpdate(_CSSUpdateBase):
source_type: Literal["daylight"] = "daylight"
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")
latitude: Optional[float] = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(
use_real_time: bool | None = Field(None, description="Use wall-clock time")
latitude: float | None = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: float | None = Field(
None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0
)
@@ -602,73 +597,71 @@ class CandlelightCSSUpdate(_CSSUpdateBase):
source_type: Literal["candlelight"] = "candlelight"
color: Any = Field(default=None, description="Candle color [R,G,B]")
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
)
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)")
candle_type: Optional[str] = Field(None, description="Candle type preset")
candle_type: str | None = Field(None, description="Candle type preset")
class ProcessedCSSUpdate(_CSSUpdateBase):
source_type: Literal["processed"] = "processed"
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Processing template ID")
input_source_id: str | None = Field(None, description="Input color strip source ID")
processing_template_id: str | None = Field(None, description="Processing template ID")
class WeatherCSSUpdate(_CSSUpdateBase):
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)")
temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)")
class KeyColorsCSSUpdate(_CSSUpdateBase):
source_type: Literal["key_colors"] = "key_colors"
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
rectangles: Optional[List[dict]] = Field(None, description="Named screen regions")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
picture_source_id: str | None = Field(None, description="Picture source ID")
rectangles: List[dict] | None = Field(None, description="Named screen regions")
interpolation_mode: str | None = Field(None, description="Interpolation mode")
smoothing: Any = Field(default=None, description="Temporal smoothing (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"
)
class MathWaveCSSUpdate(_CSSUpdateBase):
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)")
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):
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)")
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[
Union[
Annotated[PictureCSSUpdate, Tag("picture")],
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")],
Annotated[SingleColorCSSUpdate, Tag("single_color")],
Annotated[GradientCSSUpdate, Tag("gradient")],
Annotated[EffectCSSUpdate, Tag("effect")],
Annotated[CompositeCSSUpdate, Tag("composite")],
Annotated[MappedCSSUpdate, Tag("mapped")],
Annotated[AudioCSSUpdate, Tag("audio")],
Annotated[ApiInputCSSUpdate, Tag("api_input")],
Annotated[NotificationCSSUpdate, Tag("notification")],
Annotated[DaylightCSSUpdate, Tag("daylight")],
Annotated[CandlelightCSSUpdate, Tag("candlelight")],
Annotated[ProcessedCSSUpdate, Tag("processed")],
Annotated[WeatherCSSUpdate, Tag("weather")],
Annotated[KeyColorsCSSUpdate, Tag("key_colors")],
Annotated[MathWaveCSSUpdate, Tag("math_wave")],
Annotated[GameEventCSSUpdate, Tag("game_event")],
],
Annotated[PictureCSSUpdate, Tag("picture")]
| Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")]
| Annotated[SingleColorCSSUpdate, Tag("single_color")]
| Annotated[GradientCSSUpdate, Tag("gradient")]
| Annotated[EffectCSSUpdate, Tag("effect")]
| Annotated[CompositeCSSUpdate, Tag("composite")]
| Annotated[MappedCSSUpdate, Tag("mapped")]
| Annotated[AudioCSSUpdate, Tag("audio")]
| Annotated[ApiInputCSSUpdate, Tag("api_input")]
| Annotated[NotificationCSSUpdate, Tag("notification")]
| Annotated[DaylightCSSUpdate, Tag("daylight")]
| Annotated[CandlelightCSSUpdate, Tag("candlelight")]
| Annotated[ProcessedCSSUpdate, Tag("processed")]
| Annotated[WeatherCSSUpdate, Tag("weather")]
| Annotated[KeyColorsCSSUpdate, Tag("key_colors")]
| Annotated[MathWaveCSSUpdate, Tag("math_wave")]
| Annotated[GameEventCSSUpdate, Tag("game_event")],
Discriminator("source_type"),
]
@@ -699,17 +692,17 @@ class SegmentPayload(BaseModel):
``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)"
)
length: Optional[int] = Field(
length: int | None = Field(
None,
ge=1,
description="Number of LEDs in segment (default = led_count - start)",
)
mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode")
color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]")
colors: Optional[List[List[int]]] = Field(
color: List[int] | None = Field(None, description="RGB for solid mode [R,G,B]")
colors: List[List[int]] | None = Field(
None, description="Colors for per_pixel/gradient [[R,G,B],...]"
)
@@ -742,12 +735,10 @@ class ColorPushRequest(BaseModel):
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)"
)
segments: Optional[List[SegmentPayload]] = Field(
None, description="Segment-based color updates"
)
segments: List[SegmentPayload] | None = Field(None, description="Segment-based color updates")
@model_validator(mode="after")
def _require_colors_or_segments(self) -> "ColorPushRequest":
@@ -759,8 +750,8 @@ class ColorPushRequest(BaseModel):
class NotifyRequest(BaseModel):
"""Request to trigger a notification on a notification color strip source."""
app: Optional[str] = Field(None, description="App name for color lookup")
color: Optional[str] = Field(None, description="Hex color override (#RRGGBB)")
app: str | None = Field(None, description="App name for color lookup")
color: str | None = Field(None, description="Hex color override (#RRGGBB)")
class CSSCalibrationTestRequest(BaseModel):
+6 -6
View File
@@ -1,7 +1,7 @@
"""Shared schemas used across multiple route modules."""
from datetime import datetime
from typing import Dict, Optional
from typing import Dict
from pydantic import BaseModel, Field
@@ -11,7 +11,7 @@ class ErrorResponse(BaseModel):
error: str = Field(description="Error type")
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")
@@ -19,11 +19,11 @@ class CaptureImage(BaseModel):
"""Captured image with metadata."""
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")
height: int = Field(description="Original image height in pixels")
thumbnail_width: Optional[int] = Field(None, description="Thumbnail width (if resized)")
thumbnail_height: Optional[int] = Field(None, description="Thumbnail height (if resized)")
thumbnail_width: int | None = Field(None, description="Thumbnail width (if resized)")
thumbnail_height: int | None = Field(None, description="Thumbnail height (if resized)")
class BorderExtraction(BaseModel):
@@ -48,7 +48,7 @@ class TemplateTestResponse(BaseModel):
"""Response from template test."""
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)"
)
performance: PerformanceMetrics = Field(description="Performance metrics")
+90 -100
View File
@@ -1,7 +1,7 @@
"""Device-related schemas (CRUD, calibration, device state)."""
from datetime import datetime
from typing import Dict, List, Literal, Optional
from typing import Dict, List, Literal
from pydantic import BaseModel, Field
@@ -10,149 +10,145 @@ class DeviceCreate(BaseModel):
"""Request to create/attach an LED device."""
name: str = Field(description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field(
url: str | None = Field(
None,
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)")
led_count: Optional[int] = Field(
led_count: int | None = Field(
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)")
auto_shutdown: Optional[bool] = Field(
baud_rate: int | None = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: bool | None = Field(
default=None,
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)"
)
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
rgbw: bool | None = Field(None, description="RGBW mode (mock devices)")
zone_mode: str | None = Field(None, description="OpenRGB zone mode: combined or separate")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
# DMX (Art-Net / sACN) fields
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field(
None, ge=0, le=32767, description="DMX start universe"
)
dmx_start_channel: Optional[int] = Field(
dmx_protocol: str | None = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: int | None = Field(None, ge=0, le=32767, description="DMX start universe")
dmx_start_channel: int | None = Field(
None, ge=1, le=512, description="DMX start channel (1-512)"
)
# 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)"
)
ddp_destination_id: Optional[int] = Field(
ddp_destination_id: int | None = Field(
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,
ge=0,
le=5,
description="DDP color order: 0=GRB 1=RGB 2=BRG 3=RBG 4=BGR 5=GBR (most receivers expect RGB)",
)
# 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)"
)
espnow_channel: Optional[int] = Field(
None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)"
)
espnow_channel: int | None = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)")
# Philips Hue fields
hue_username: Optional[str] = Field(None, description="Hue bridge username (from pairing)")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key (hex)")
hue_entertainment_group_id: Optional[str] = Field(
hue_username: str | None = Field(None, description="Hue bridge username (from pairing)")
hue_client_key: str | None = Field(None, description="Hue entertainment client key (hex)")
hue_entertainment_group_id: str | None = Field(
None, description="Hue entertainment group/zone ID"
)
# Yeelight fields
yeelight_min_interval_ms: Optional[int] = Field(
yeelight_min_interval_ms: int | None = Field(
None,
ge=0,
le=10000,
description="Yeelight client-side rate limit between commands in ms (default 500)",
)
# WiZ fields
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 between commands in ms (default 50)",
)
# LIFX fields
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 between commands in ms (default 50)",
)
# Govee fields
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 between commands in ms (default 50)",
)
# OPC fields
opc_channel: Optional[int] = Field(
opc_channel: int | None = Field(
None,
ge=0,
le=255,
description="OPC channel (0 = broadcast to all channels on the server)",
)
# Nanoleaf fields
nanoleaf_token: Optional[str] = Field(
nanoleaf_token: str | None = Field(
None,
max_length=512,
description="Nanoleaf auth token returned by the pairing handshake",
)
nanoleaf_min_interval_ms: Optional[int] = Field(
nanoleaf_min_interval_ms: int | None = Field(
None,
ge=0,
le=10000,
description="Nanoleaf client-side rate limit between commands in ms (default 100)",
)
# 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"
)
spi_led_type: Optional[str] = Field(
spi_led_type: str | None = Field(
None, description="LED chipset: WS2812, WS2812B, WS2811, SK6812, SK6812_RGBW"
)
# Razer Chroma fields
chroma_device_type: Optional[str] = Field(
chroma_device_type: str | None = Field(
None,
description="Chroma peripheral type: keyboard, mouse, mousepad, headset, chromalink, keypad",
)
# 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"
)
# BLE controller fields
ble_family: Optional[str] = Field(
ble_family: str | None = Field(
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",
)
default_css_processing_template_id: Optional[str] = Field(
default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID"
)
# 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)"
)
group_mode: Optional[str] = Field(
group_mode: str | None = Field(
None,
description="Group mode: sequence (LEDs concatenated) or independent (each child gets full strip resampled)",
)
# Custom card icon (frontend display only)
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library (e.g. 'mouse', 'motherboard'). Empty/null hides the plate.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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):
"""Request to update device information."""
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field(None, description="Device URL or serial port")
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
led_count: Optional[int] = Field(
name: str | None = Field(None, description="Device name", min_length=1, max_length=100)
url: str | None = Field(None, description="Device URL or serial port")
enabled: bool | None = Field(None, description="Whether device is enabled")
led_count: int | None = Field(
None,
ge=1,
le=10000,
description="Number of LEDs (for devices with manual_led_count capability)",
)
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: Optional[bool] = Field(None, description="Turn off device when server stops")
send_latency_ms: Optional[int] = Field(
baud_rate: int | None = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: bool | None = Field(None, description="Turn off device when server stops")
send_latency_ms: int | None = Field(
None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)"
)
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
tags: Optional[List[str]] = None
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field(
None, ge=0, le=32767, description="DMX start universe"
)
dmx_start_channel: Optional[int] = Field(
rgbw: bool | None = Field(None, description="RGBW mode (mock devices)")
zone_mode: str | None = Field(None, description="OpenRGB zone mode: combined or separate")
tags: List[str] | None = None
dmx_protocol: str | None = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: int | None = Field(None, ge=0, le=32767, description="DMX start universe")
dmx_start_channel: int | None = Field(
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)"
)
ddp_destination_id: Optional[int] = 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")
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address")
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel")
hue_username: Optional[str] = Field(None, description="Hue bridge username")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key")
hue_entertainment_group_id: Optional[str] = Field(
None, description="Hue entertainment group ID"
)
yeelight_min_interval_ms: Optional[int] = Field(
ddp_destination_id: int | None = Field(None, ge=0, le=255, description="DDP destination ID")
ddp_color_order: int | None = Field(None, ge=0, le=5, description="DDP color order code")
espnow_peer_mac: str | None = Field(None, description="ESP-NOW peer MAC address")
espnow_channel: int | None = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel")
hue_username: str | None = Field(None, description="Hue bridge username")
hue_client_key: str | None = Field(None, description="Hue entertainment client key")
hue_entertainment_group_id: str | None = Field(None, description="Hue entertainment group ID")
yeelight_min_interval_ms: int | None = Field(
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"
)
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"
)
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"
)
opc_channel: Optional[int] = Field(
None, ge=0, le=255, description="OPC channel (0 = broadcast)"
)
nanoleaf_token: Optional[str] = Field(None, max_length=512, description="Nanoleaf auth token")
nanoleaf_min_interval_ms: Optional[int] = Field(
opc_channel: int | None = Field(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(
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_led_type: Optional[str] = Field(None, description="LED chipset type")
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type")
ble_family: Optional[str] = Field(
spi_speed_hz: int | None = Field(None, ge=100000, le=4000000, description="SPI clock speed")
spi_led_type: str | None = Field(None, description="LED chipset type")
chroma_device_type: str | None = Field(None, description="Chroma peripheral type")
gamesense_device_type: str | None = Field(None, description="GameSense device type")
ble_family: str | None = Field(
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"
)
default_css_processing_template_id: Optional[str] = Field(
default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID"
)
# 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)"
)
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
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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)",
)
# 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)"
)
# Simple mode fields
@@ -388,7 +378,7 @@ class DeviceResponse(BaseModel):
device_type: str = Field(default="wled", description="LED device type")
led_count: int = Field(description="Total number of LEDs")
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(
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_type: str = Field(default="wled", description="LED device type")
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_name: Optional[str] = Field(None, description="Device name reported by firmware")
device_version: Optional[str] = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field(
device_latency_ms: float | None = Field(None, description="Health check latency in ms")
device_name: str | None = Field(None, description="Device name reported by firmware")
device_version: str | None = Field(None, description="Firmware version")
device_led_count: int | None = Field(None, description="LED count reported by device")
device_rgbw: bool | None = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: str | None = Field(
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)"
)
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")
device_last_checked: datetime | None = Field(None, description="Last health check time")
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_edges: List[str] = Field(
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")
ip: str = Field(description="IP address")
mac: str = Field(default="", description="MAC address")
led_count: Optional[int] = Field(None, description="LED count (if reachable)")
version: Optional[str] = Field(None, description="Firmware version")
ble_family: Optional[str] = Field(
led_count: int | None = Field(None, description="LED count (if reachable)")
version: str | None = Field(None, description="Firmware version")
ble_family: str | None = Field(
None, description="Detected BLE protocol family (sp110e/triones/zengge/govee)"
)
already_added: bool = Field(
+3 -3
View File
@@ -1,6 +1,6 @@
"""Filter-related schemas."""
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from pydantic import BaseModel, Field
@@ -22,10 +22,10 @@ class FilterOptionDefSchema(BaseModel):
min_value: Any = Field(description="Minimum value")
max_value: Any = Field(description="Maximum value")
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"
)
max_length: Optional[int] = Field(
max_length: int | None = Field(
default=None, description="Maximum string length for string type"
)
@@ -1,11 +1,10 @@
"""Pydantic schemas for game integration API endpoints."""
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from pydantic import BaseModel, Field
# ── Event Mapping ──────────────────────────────────────────────────────────
@@ -40,14 +39,14 @@ class GameIntegrationCreate(BaseModel):
event_mappings: List[EventMappingSchema] = Field(
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")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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):
"""Request to update a game integration config."""
name: Optional[str] = Field(None, description="Integration name", min_length=1, max_length=100)
adapter_type: Optional[str] = Field(None, description="Adapter type identifier", min_length=1)
enabled: Optional[bool] = Field(None, description="Whether integration is active")
adapter_config: Optional[Dict[str, Any]] = Field(None, description="Adapter-specific settings")
event_mappings: Optional[List[EventMappingSchema]] = Field(
name: str | None = Field(None, description="Integration name", min_length=1, max_length=100)
adapter_type: str | None = Field(None, description="Adapter type identifier", min_length=1)
enabled: bool | None = Field(None, description="Whether integration is active")
adapter_config: Dict[str, Any] | None = Field(None, description="Adapter-specific settings")
event_mappings: List[EventMappingSchema] | None = Field(
None, description="Event-to-effect mappings"
)
description: Optional[str] = Field(None, description="Integration description", max_length=500)
tags: Optional[List[str]] = Field(None, description="User-defined tags")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Integration description", max_length=500)
tags: List[str] | None = Field(None, description="User-defined tags")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
created_at: datetime = Field(description="Creation 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")
icon: Optional[str] = Field(
icon: str | None = Field(
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.",
@@ -158,7 +157,7 @@ class GameIntegrationStatusResponse(BaseModel):
integration_id: str = Field(description="Integration ID")
enabled: bool = Field(description="Whether integration is active")
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_counts_by_type: Dict[str, int] = Field(
default_factory=dict, description="Event counts per event type"
+13 -13
View File
@@ -1,7 +1,7 @@
"""Gradient schemas (CRUD)."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -18,14 +18,14 @@ class GradientCreate(BaseModel):
name: str = Field(description="Gradient name", min_length=1, max_length=100)
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")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -35,16 +35,16 @@ class GradientCreate(BaseModel):
class GradientUpdate(BaseModel):
"""Request to update a gradient."""
name: Optional[str] = Field(None, description="Gradient name", min_length=1, max_length=100)
stops: Optional[List[GradientStopSchema]] = Field(None, description="Color stops", min_length=2)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Gradient name", min_length=1, max_length=100)
stops: List[GradientStopSchema] | None = Field(None, description="Color stops", min_length=2)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
stops: List[GradientStopSchema] = Field(description="Color stops")
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")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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)."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -16,14 +16,14 @@ class HomeAssistantSourceCreate(BaseModel):
entity_filters: List[str] = Field(
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")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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):
"""Request to update a Home Assistant source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
host: Optional[str] = Field(None, description="HA host:port", min_length=1)
token: Optional[str] = Field(None, description="Long-Lived Access Token", min_length=1)
use_ssl: Optional[bool] = Field(None, description="Use wss://")
entity_filters: Optional[List[str]] = Field(None, description="Entity ID filter patterns")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
host: str | None = Field(None, description="HA host:port", min_length=1)
token: str | None = Field(None, description="Long-Lived Access Token", min_length=1)
use_ssl: bool | None = Field(None, description="Use wss://")
entity_filters: List[str] | None = Field(None, description="Entity ID filter patterns")
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
connected: bool = Field(default=False, description="Whether the WebSocket connection is active")
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")
icon: Optional[str] = Field(
icon: str | None = Field(
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.",
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
token: Optional[str] = Field(
token: str | None = Field(
None,
description=(
"Long-Lived Access Token. Redacted as '***' unless the request "
@@ -112,9 +112,9 @@ class HomeAssistantTestResponse(BaseModel):
"""Connection test result."""
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")
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):
@@ -2,12 +2,11 @@
import re
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 pydantic import BaseModel, Field, field_validator
# RFC 7230 token chars for header names + reject any control character in values.
_HEADER_NAME_RE = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$")
_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)
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)
icon: Optional[str] = Field(None, max_length=64)
icon_color: Optional[str] = Field(None, max_length=32)
icon: str | None = Field(None, max_length=64)
icon_color: str | None = Field(None, max_length=32)
@field_validator("headers")
@classmethod
@@ -88,16 +87,16 @@ class HTTPEndpointUpdate(BaseModel):
field (or send ``null``) to keep it.
"""
name: Optional[str] = Field(None, min_length=1, max_length=100)
url: Optional[str] = Field(None, min_length=1)
method: Optional[Literal["GET", "HEAD"]] = None
auth_token: Optional[str] = Field(None, description="null = keep existing; '' = clear.")
headers: Optional[Dict[str, str]] = None
timeout_s: Optional[float] = Field(None, gt=0)
description: Optional[str] = Field(None, max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(None, max_length=64)
icon_color: Optional[str] = Field(None, max_length=32)
name: str | None = Field(None, min_length=1, max_length=100)
url: str | None = Field(None, min_length=1)
method: Literal["GET", "HEAD"] | None = None
auth_token: str | None = Field(None, description="null = keep existing; '' = clear.")
headers: Dict[str, str] | None = None
timeout_s: float | None = Field(None, gt=0)
description: str | None = Field(None, max_length=500)
tags: List[str] | None = None
icon: str | None = Field(None, max_length=64)
icon_color: str | None = Field(None, max_length=32)
@field_validator("headers")
@classmethod
@@ -125,10 +124,10 @@ class HTTPEndpointResponse(BaseModel):
auth_token_set: bool = False
headers: Dict[str, str] = Field(default_factory=dict)
timeout_s: float
description: Optional[str] = None
description: str | None = None
tags: List[str] = Field(default_factory=list)
icon: Optional[str] = Field(None, max_length=64)
icon_color: Optional[str] = Field(None, max_length=32)
icon: str | None = Field(None, max_length=64)
icon_color: str | None = Field(None, max_length=32)
created_at: datetime
updated_at: datetime
@@ -160,7 +159,7 @@ class HTTPTestRequest(BaseModel):
class HTTPTestResponse(BaseModel):
success: bool
status_code: Optional[int] = None
body_preview: Optional[str] = Field(None, description="First 500 chars of the body")
status_code: int | None = None
body_preview: str | None = Field(None, description="First 500 chars of the body")
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)."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -16,14 +16,14 @@ class MQTTSourceCreate(BaseModel):
password: str = Field(default="", description="Broker password (optional)")
client_id: str = Field(default="ledgrab", description="MQTT client ID")
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")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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):
"""Request to update an MQTT source."""
name: Optional[str] = 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_port: Optional[int] = Field(None, description="MQTT broker port", ge=1, le=65535)
username: Optional[str] = Field(None, description="Broker username")
password: Optional[str] = Field(None, description="Broker password")
client_id: Optional[str] = Field(None, description="MQTT client ID")
base_topic: Optional[str] = Field(None, description="Base topic prefix")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
broker_host: str | None = Field(None, description="MQTT broker hostname or IP", min_length=1)
broker_port: int | None = Field(None, description="MQTT broker port", ge=1, le=65535)
username: str | None = Field(None, description="Broker username")
password: str | None = Field(None, description="Broker password")
client_id: str | None = Field(None, description="MQTT client ID")
base_topic: str | None = Field(None, description="Base topic prefix")
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
base_topic: str = Field(description="Base topic prefix")
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")
icon: Optional[str] = Field(
icon: str | None = Field(
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.",
@@ -93,7 +93,7 @@ class MQTTTestResponse(BaseModel):
"""Connection test result."""
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):
+118 -138
View File
@@ -1,7 +1,7 @@
"""Output target schemas — discriminated unions per target type."""
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
@@ -11,7 +11,7 @@ DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
# 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}."""
@@ -38,7 +38,7 @@ class HALightMappingSchema(BaseModel):
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_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)"
)
@@ -52,7 +52,7 @@ class Z2MLightMappingSchema(BaseModel):
)
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)")
brightness_scale: Optional[BindableFloatInput] = Field(
brightness_scale: BindableFloatInput | None = Field(
default=1.0, description="Brightness multiplier (bindable)"
)
@@ -67,7 +67,7 @@ class _OutputTargetResponseBase(BaseModel):
id: str = Field(description="Target ID")
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")
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")
@@ -79,13 +79,13 @@ class LedOutputTargetResponse(_OutputTargetResponseBase):
target_type: Literal["led"] = "led"
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable)")
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
fps: BindableFloatInput | None = Field(None, description="Target send FPS (bindable)")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(
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)"
)
adaptive_fps: bool = Field(
@@ -110,20 +110,20 @@ class HALightOutputTargetResponse(_OutputTargetResponseBase):
description="Colour value source ID (used when source_kind='color_vs'); "
"must reference a value source whose return_type='color'.",
)
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
ha_light_mappings: List[HALightMappingSchema] | None = Field(
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
update_rate: BindableFloatInput | None = Field(
None, description="Service call rate Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
None, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
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)"
)
stop_action: Literal["none", "turn_off", "restore"] = Field(
@@ -151,24 +151,24 @@ class Z2MLightOutputTargetResponse(_OutputTargetResponseBase):
default="",
description="Colour value source ID (used when source_kind='color_vs').",
)
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field(
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
z2m_light_mappings: List[Z2MLightMappingSchema] | None = Field(
None, description="LED-to-bulb mappings (by Z2M friendly_name)"
)
base_topic: str = Field(
default="zigbee2mqtt",
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)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
None, description="Z2M transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
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)"
)
stop_action: Literal["none", "turn_off"] = Field(
@@ -179,11 +179,9 @@ class Z2MLightOutputTargetResponse(_OutputTargetResponseBase):
OutputTargetResponse = Annotated[
Union[
Annotated[LedOutputTargetResponse, Tag("led")],
Annotated[HALightOutputTargetResponse, Tag("ha_light")],
Annotated[Z2MLightOutputTargetResponse, Tag("z2m_light")],
],
Annotated[LedOutputTargetResponse, Tag("led")]
| Annotated[HALightOutputTargetResponse, Tag("ha_light")]
| Annotated[Z2MLightOutputTargetResponse, Tag("z2m_light")],
Discriminator("target_type"),
]
@@ -196,12 +194,12 @@ class _OutputTargetCreateBase(BaseModel):
"""Shared fields for all output target create requests."""
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")
icon: Optional[str] = Field(
icon: str | None = Field(
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"
)
@@ -210,10 +208,8 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
target_type: Literal["led"] = "led"
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
fps: Optional[BindableFloatInput] = Field(
brightness: BindableFloatInput | None = Field(default=1.0, description="Brightness (bindable)")
fps: BindableFloatInput | None = Field(
default=30, description="Target send FPS (bindable, 1-90)"
)
keepalive_interval: float = Field(
@@ -228,7 +224,7 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
ge=5,
le=600,
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
@@ -257,22 +253,20 @@ class HALightOutputTargetCreate(_OutputTargetCreateBase):
default="",
description="Colour value source ID (used when source_kind='color_vs').",
)
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
brightness: BindableFloatInput | None = Field(default=1.0, description="Brightness (bindable)")
ha_light_mappings: List[HALightMappingSchema] | None = Field(
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)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
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)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
@@ -299,10 +293,8 @@ class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
default="",
description="Colour value source ID (used when source_kind='color_vs').",
)
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field(
brightness: BindableFloatInput | None = Field(default=1.0, description="Brightness (bindable)")
z2m_light_mappings: List[Z2MLightMappingSchema] | None = Field(
None, description="LED-to-bulb mappings (by Z2M friendly_name)"
)
base_topic: str = Field(
@@ -310,16 +302,16 @@ class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
max_length=128,
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)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
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)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
@@ -330,11 +322,9 @@ class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
OutputTargetCreate = Annotated[
Union[
Annotated[LedOutputTargetCreate, Tag("led")],
Annotated[HALightOutputTargetCreate, Tag("ha_light")],
Annotated[Z2MLightOutputTargetCreate, Tag("z2m_light")],
],
Annotated[LedOutputTargetCreate, Tag("led")]
| Annotated[HALightOutputTargetCreate, Tag("ha_light")]
| Annotated[Z2MLightOutputTargetCreate, Tag("z2m_light")],
Discriminator("target_type"),
]
@@ -346,15 +336,15 @@ OutputTargetCreate = Annotated[
class _OutputTargetUpdateBase(BaseModel):
"""Shared fields for all output target update requests."""
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Target name", min_length=1, max_length=100)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Custom icon id; pass empty string to clear and inherit from device.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon; empty string clears.",
@@ -363,103 +353,99 @@ class _OutputTargetUpdateBase(BaseModel):
class LedOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["led"] = "led"
device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable, 1-90)")
keepalive_interval: Optional[float] = Field(
device_id: str | None = Field(None, description="LED device ID")
color_strip_source_id: str | None = Field(None, description="Color strip source ID")
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
fps: BindableFloatInput | None = Field(None, description="Target send FPS (bindable, 1-90)")
keepalive_interval: float | None = Field(
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
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
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"
)
protocol: Optional[str] = Field(
protocol: str | None = Field(
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
)
class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
source_kind: Optional[Literal["css", "color_vs"]] = Field(
ha_source_id: str | None = Field(None, description="Home Assistant source ID")
source_kind: Literal["css", "color_vs"] | None = Field(
None,
description="Colour source kind: 'css' or 'color_vs'.",
)
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
color_value_source_id: Optional[str] = Field(
color_strip_source_id: str | None = Field(None, description="Color strip source ID")
color_value_source_id: str | None = Field(
None,
description="Colour value source ID (used when source_kind='color_vs').",
)
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
ha_light_mappings: List[HALightMappingSchema] | None = Field(
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
update_rate: BindableFloatInput | None = Field(
None, description="Service call rate Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
None, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
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)"
)
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'."
)
class Z2MLightOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["z2m_light"] = "z2m_light"
mqtt_source_id: Optional[str] = Field(
mqtt_source_id: str | None = Field(
None,
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'."
)
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
color_value_source_id: Optional[str] = Field(
color_strip_source_id: str | None = Field(None, description="Color strip source ID")
color_value_source_id: str | None = Field(
None, description="Colour value source ID (used when source_kind='color_vs')."
)
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field(
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
z2m_light_mappings: List[Z2MLightMappingSchema] | None = Field(
None, description="LED-to-bulb mappings (by Z2M friendly_name)"
)
base_topic: Optional[str] = Field(
None, max_length=128, description="Z2M MQTT base topic prefix."
)
update_rate: Optional[BindableFloatInput] = Field(
base_topic: str | None = Field(None, max_length=128, description="Z2M MQTT base topic prefix.")
update_rate: BindableFloatInput | None = Field(
None, description="Publish rate Hz (bindable; 0.5-10)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
None, description="Z2M transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
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)"
)
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'."
)
OutputTargetUpdate = Annotated[
Union[
Annotated[LedOutputTargetUpdate, Tag("led")],
Annotated[HALightOutputTargetUpdate, Tag("ha_light")],
Annotated[Z2MLightOutputTargetUpdate, Tag("z2m_light")],
],
Annotated[LedOutputTargetUpdate, Tag("led")]
| Annotated[HALightOutputTargetUpdate, Tag("ha_light")]
| Annotated[Z2MLightOutputTargetUpdate, Tag("z2m_light")],
Discriminator("target_type"),
]
@@ -479,75 +465,69 @@ class TargetProcessingState(BaseModel):
"""Processing state for an output target."""
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")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_potential: Optional[float] = Field(
fps_actual: float | None = Field(None, description="Actual FPS achieved")
fps_potential: float | None = Field(
None, description="Potential FPS (processing speed without throttle)"
)
fps_target: Optional[int] = Field(None, description="Target FPS")
fps_capture: Optional[int] = Field(
fps_target: int | None = Field(None, description="Target FPS")
fps_capture: int | None = Field(
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_keepalive: Optional[int] = Field(
None, description="Keepalive frames sent during standby"
)
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
timing_extract_ms: Optional[float] = Field(
None, description="Border pixel extraction time (ms)"
)
timing_map_leds_ms: Optional[float] = Field(None, description="LED color mapping time (ms)")
timing_smooth_ms: Optional[float] = Field(None, description="Temporal smoothing time (ms)")
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(
frames_skipped: int | None = Field(None, description="Frames skipped (no screen change)")
frames_keepalive: int | None = Field(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)")
timing_extract_ms: float | None = Field(None, description="Border pixel extraction time (ms)")
timing_map_leds_ms: float | None = Field(None, description="LED color mapping time (ms)")
timing_smooth_ms: float | None = Field(None, description="Temporal smoothing 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_audio_fft_ms: float | None = Field(None, description="Audio FFT analysis time (ms)")
timing_audio_render_ms: float | None = Field(
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(
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")
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_name: Optional[str] = Field(None, description="Device name reported by firmware")
device_version: Optional[str] = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field(
device_latency_ms: float | None = Field(None, description="Health check latency in ms")
device_name: str | None = Field(None, description="Device name reported by firmware")
device_version: str | None = Field(None, description="Firmware version")
device_led_count: int | None = Field(None, description="LED count reported by device")
device_rgbw: bool | None = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: str | None = Field(
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)"
)
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")
device_streaming_reachable: Optional[bool] = Field(
device_last_checked: datetime | None = Field(None, description="Last health check time")
device_error: str | None = Field(None, description="Last health check error")
device_streaming_reachable: bool | None = Field(
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):
"""Target metrics response."""
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")
fps_actual: Optional[float] = Field(None, description="Actual FPS")
fps_target: Optional[int] = Field(None, description="Target FPS")
fps_actual: float | None = Field(None, description="Actual FPS")
fps_target: int | None = Field(None, description="Target FPS")
uptime_seconds: float = Field(description="Processing uptime in seconds")
frames_processed: int = Field(description="Total frames processed")
errors_count: int = Field(description="Total error count")
last_error: Optional[str] = Field(None, description="Last error message")
last_update: Optional[datetime] = Field(None, description="Last update timestamp")
last_error: str | None = Field(None, description="Last error message")
last_update: datetime | None = Field(None, description="Last update timestamp")
class BulkTargetRequest(BaseModel):
@@ -1,7 +1,7 @@
"""Pydantic schemas for pattern template API."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class PatternTemplateCreate(BaseModel):
rectangles: List[KeyColorRectangleSchema] = Field(
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")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class PatternTemplateCreate(BaseModel):
class PatternTemplateUpdate(BaseModel):
"""Request to update a pattern template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
rectangles: Optional[List[KeyColorRectangleSchema]] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
rectangles: List[KeyColorRectangleSchema] | None = Field(
None, description="List of named rectangles"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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."""
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
@@ -15,16 +15,16 @@ class _PictureSourceResponseBase(BaseModel):
id: str = Field(description="Stream ID")
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")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -46,28 +46,26 @@ class ProcessedPictureSourceResponse(_PictureSourceResponseBase):
class StaticImagePictureSourceResponse(_PictureSourceResponseBase):
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):
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")
playback_speed: float = Field(1.0, description="Playback speed multiplier")
start_time: Optional[float] = Field(None, description="Trim start time in seconds")
end_time: Optional[float] = Field(None, description="Trim end time in seconds")
resolution_limit: Optional[int] = Field(None, description="Max width for decode")
clock_id: Optional[str] = Field(None, description="Sync clock ID")
start_time: float | None = Field(None, description="Trim start time in seconds")
end_time: float | None = Field(None, description="Trim end time in seconds")
resolution_limit: int | None = Field(None, description="Max width for decode")
clock_id: str | None = Field(None, description="Sync clock ID")
target_fps: int = Field(30, description="Target FPS")
PictureSourceResponse = Annotated[
Union[
Annotated[RawPictureSourceResponse, Tag("raw")],
Annotated[ProcessedPictureSourceResponse, Tag("processed")],
Annotated[StaticImagePictureSourceResponse, Tag("static_image")],
Annotated[VideoPictureSourceResponse, Tag("video")],
],
Annotated[RawPictureSourceResponse, Tag("raw")]
| Annotated[ProcessedPictureSourceResponse, Tag("processed")]
| Annotated[StaticImagePictureSourceResponse, Tag("static_image")]
| Annotated[VideoPictureSourceResponse, Tag("video")],
Discriminator("stream_type"),
]
@@ -80,14 +78,14 @@ class _PictureSourceCreateBase(BaseModel):
"""Shared fields for all picture source create requests."""
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")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
loop: bool = Field(True, description="Loop video playback")
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)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(
start_time: float | None = Field(None, description="Trim start time in seconds", ge=0)
end_time: float | None = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: int | None = Field(
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)
PictureSourceCreate = Annotated[
Union[
Annotated[RawPictureSourceCreate, Tag("raw")],
Annotated[ProcessedPictureSourceCreate, Tag("processed")],
Annotated[StaticImagePictureSourceCreate, Tag("static_image")],
Annotated[VideoPictureSourceCreate, Tag("video")],
],
Annotated[RawPictureSourceCreate, Tag("raw")]
| Annotated[ProcessedPictureSourceCreate, Tag("processed")]
| Annotated[StaticImagePictureSourceCreate, Tag("static_image")]
| Annotated[VideoPictureSourceCreate, Tag("video")],
Discriminator("stream_type"),
]
@@ -144,15 +140,15 @@ PictureSourceCreate = Annotated[
class _PictureSourceUpdateBase(BaseModel):
"""Shared fields for all picture source update requests."""
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Stream name", min_length=1, max_length=100)
description: str | None = Field(None, description="Stream description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -161,47 +157,43 @@ class _PictureSourceUpdateBase(BaseModel):
class RawPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["raw"] = "raw"
display_index: Optional[int] = Field(None, description="Display index", ge=0)
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
display_index: int | None = Field(None, description="Display index", ge=0)
capture_template_id: str | None = Field(None, description="Capture template ID")
target_fps: int | None = Field(None, description="Target FPS", ge=1, le=90)
class ProcessedPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["processed"] = "processed"
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
postprocessing_template_id: Optional[str] = Field(
None, description="Postprocessing template ID"
)
source_stream_id: str | None = Field(None, description="Source stream ID")
postprocessing_template_id: str | None = Field(None, description="Postprocessing template ID")
class StaticImagePictureSourceUpdate(_PictureSourceUpdateBase):
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):
stream_type: Literal["video"] = "video"
video_asset_id: Optional[str] = Field(None, description="Video asset ID")
loop: Optional[bool] = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field(
video_asset_id: str | None = Field(None, description="Video asset ID")
loop: bool | None = Field(None, description="Loop video playback")
playback_speed: float | None = Field(
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)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(
start_time: float | None = Field(None, description="Trim start time in seconds", ge=0)
end_time: float | None = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: int | None = Field(
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")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
clock_id: str | None = Field(None, description="Sync clock ID for frame-accurate timing")
target_fps: int | None = Field(None, description="Target FPS", ge=1, le=90)
PictureSourceUpdate = Annotated[
Union[
Annotated[RawPictureSourceUpdate, Tag("raw")],
Annotated[ProcessedPictureSourceUpdate, Tag("processed")],
Annotated[StaticImagePictureSourceUpdate, Tag("static_image")],
Annotated[VideoPictureSourceUpdate, Tag("video")],
],
Annotated[RawPictureSourceUpdate, Tag("raw")]
| Annotated[ProcessedPictureSourceUpdate, Tag("processed")]
| Annotated[StaticImagePictureSourceUpdate, Tag("static_image")]
| Annotated[VideoPictureSourceUpdate, Tag("video")],
Discriminator("stream_type"),
]
@@ -246,7 +238,7 @@ class ImageValidateResponse(BaseModel):
"""Response from image validation."""
valid: bool = Field(description="Whether the image source is accessible and valid")
width: Optional[int] = Field(None, description="Image width in pixels")
height: Optional[int] = Field(None, description="Image height in pixels")
preview: Optional[str] = Field(None, description="Base64-encoded JPEG thumbnail")
error: Optional[str] = Field(None, description="Error message if invalid")
width: int | None = Field(None, description="Image width in pixels")
height: int | None = Field(None, description="Image height in pixels")
preview: str | None = Field(None, description="Base64-encoded JPEG thumbnail")
error: str | None = Field(None, description="Error message if invalid")
@@ -1,7 +1,7 @@
"""Postprocessing template schemas."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class PostprocessingTemplateCreate(BaseModel):
filters: List[FilterInstanceSchema] = Field(
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")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class PostprocessingTemplateCreate(BaseModel):
class PostprocessingTemplateUpdate(BaseModel):
"""Request to update a postprocessing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
filters: List[FilterInstanceSchema] | None = Field(
None, description="Ordered list of filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -19,16 +19,14 @@ class ScenePresetCreate(BaseModel):
name: str = Field(description="Preset name", min_length=1, max_length=100)
description: str = Field(default="", max_length=500)
target_ids: Optional[List[str]] = Field(
None, description="Target IDs to capture (all if omitted)"
)
target_ids: List[str] | None = Field(None, description="Target IDs to capture (all if omitted)")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -38,20 +36,20 @@ class ScenePresetCreate(BaseModel):
class ScenePresetUpdate(BaseModel):
"""Update scene preset metadata and optionally change which targets are included."""
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
order: Optional[int] = None
target_ids: Optional[List[str]] = Field(
name: str | None = Field(None, min_length=1, max_length=100)
description: str | None = Field(None, max_length=500)
order: int | None = None
target_ids: List[str] | None = Field(
None,
description="Update target list: keep state for existing, capture fresh for new, drop removed",
)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -67,12 +65,12 @@ class ScenePresetResponse(BaseModel):
targets: List[TargetSnapshotSchema]
order: int
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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)."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -11,14 +11,14 @@ class SyncClockCreate(BaseModel):
name: str = Field(description="Clock name", min_length=1, max_length=100)
speed: float = Field(default=1.0, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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):
"""Request to update a synchronization clock."""
name: Optional[str] = Field(None, description="Clock name", min_length=1, max_length=100)
speed: Optional[float] = Field(None, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Clock name", min_length=1, max_length=100)
speed: float | None = Field(None, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
name: str = Field(description="Clock name")
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")
icon: Optional[str] = Field(
icon: str | None = Field(
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.",
+14 -14
View File
@@ -1,7 +1,7 @@
"""Capture template and engine schemas."""
from datetime import datetime
from typing import Dict, List, Optional
from typing import Dict, List
from pydantic import BaseModel, Field
@@ -12,14 +12,14 @@ class TemplateCreate(BaseModel):
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_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")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -29,17 +29,17 @@ class TemplateCreate(BaseModel):
class TemplateUpdate(BaseModel):
"""Request to update a template."""
name: Optional[str] = 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_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: str | None = Field(None, description="Capture engine type (mss, dxcam, wgc)")
engine_config: Dict | None = Field(None, description="Engine-specific configuration")
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
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."
)
+115 -123
View File
@@ -1,7 +1,7 @@
"""Value source schemas — discriminated unions per source type."""
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
@@ -15,14 +15,14 @@ class _ValueSourceResponseBase(BaseModel):
id: str = Field(description="Source ID")
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")
icon: Optional[str] = Field(
icon: str | None = Field(
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.",
@@ -100,7 +100,7 @@ class AnimatedColorValueSourceResponse(_ValueSourceResponseBase):
colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]")
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")
clock_id: Optional[str] = Field(
clock_id: str | None = Field(
None, description="Optional sync clock ID for shared timing (overrides speed)"
)
@@ -163,22 +163,20 @@ class HTTPValueSourceResponse(_ValueSourceResponseBase):
ValueSourceResponse = Annotated[
Union[
Annotated[StaticValueSourceResponse, Tag("static")],
Annotated[AnimatedValueSourceResponse, Tag("animated")],
Annotated[AudioValueSourceResponse, Tag("audio")],
Annotated[AdaptiveTimeValueSourceResponse, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceResponse, Tag("adaptive_scene")],
Annotated[DaylightValueSourceResponse, Tag("daylight")],
Annotated[StaticColorValueSourceResponse, Tag("static_color")],
Annotated[AnimatedColorValueSourceResponse, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceResponse, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceResponse, Tag("ha_entity")],
Annotated[GradientMapValueSourceResponse, Tag("gradient_map")],
Annotated[CSSExtractValueSourceResponse, Tag("css_extract")],
Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")],
Annotated[HTTPValueSourceResponse, Tag("http")],
],
Annotated[StaticValueSourceResponse, Tag("static")]
| Annotated[AnimatedValueSourceResponse, Tag("animated")]
| Annotated[AudioValueSourceResponse, Tag("audio")]
| Annotated[AdaptiveTimeValueSourceResponse, Tag("adaptive_time")]
| Annotated[AdaptiveSceneValueSourceResponse, Tag("adaptive_scene")]
| Annotated[DaylightValueSourceResponse, Tag("daylight")]
| Annotated[StaticColorValueSourceResponse, Tag("static_color")]
| Annotated[AnimatedColorValueSourceResponse, Tag("animated_color")]
| Annotated[AdaptiveTimeColorValueSourceResponse, Tag("adaptive_time_color")]
| Annotated[HAEntityValueSourceResponse, Tag("ha_entity")]
| Annotated[GradientMapValueSourceResponse, Tag("gradient_map")]
| Annotated[CSSExtractValueSourceResponse, Tag("css_extract")]
| Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")]
| Annotated[HTTPValueSourceResponse, Tag("http")],
Discriminator("source_type"),
]
@@ -191,14 +189,14 @@ class _ValueSourceCreateBase(BaseModel):
"""Shared fields for all value source create requests."""
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")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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(
"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)"
)
@@ -333,22 +331,20 @@ class HTTPValueSourceCreate(_ValueSourceCreateBase):
ValueSourceCreate = Annotated[
Union[
Annotated[StaticValueSourceCreate, Tag("static")],
Annotated[AnimatedValueSourceCreate, Tag("animated")],
Annotated[AudioValueSourceCreate, Tag("audio")],
Annotated[AdaptiveTimeValueSourceCreate, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceCreate, Tag("adaptive_scene")],
Annotated[DaylightValueSourceCreate, Tag("daylight")],
Annotated[StaticColorValueSourceCreate, Tag("static_color")],
Annotated[AnimatedColorValueSourceCreate, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceCreate, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceCreate, Tag("ha_entity")],
Annotated[GradientMapValueSourceCreate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceCreate, Tag("css_extract")],
Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")],
Annotated[HTTPValueSourceCreate, Tag("http")],
],
Annotated[StaticValueSourceCreate, Tag("static")]
| Annotated[AnimatedValueSourceCreate, Tag("animated")]
| Annotated[AudioValueSourceCreate, Tag("audio")]
| Annotated[AdaptiveTimeValueSourceCreate, Tag("adaptive_time")]
| Annotated[AdaptiveSceneValueSourceCreate, Tag("adaptive_scene")]
| Annotated[DaylightValueSourceCreate, Tag("daylight")]
| Annotated[StaticColorValueSourceCreate, Tag("static_color")]
| Annotated[AnimatedColorValueSourceCreate, Tag("animated_color")]
| Annotated[AdaptiveTimeColorValueSourceCreate, Tag("adaptive_time_color")]
| Annotated[HAEntityValueSourceCreate, Tag("ha_entity")]
| Annotated[GradientMapValueSourceCreate, Tag("gradient_map")]
| Annotated[CSSExtractValueSourceCreate, Tag("css_extract")]
| Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")]
| Annotated[HTTPValueSourceCreate, Tag("http")],
Discriminator("source_type"),
]
@@ -360,15 +356,15 @@ ValueSourceCreate = Annotated[
class _ValueSourceUpdateBase(BaseModel):
"""Shared fields for all value source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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):
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):
source_type: Literal["animated"] = "animated"
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = 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)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
waveform: str | None = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: float | None = Field(None, description="Cycles per minute", ge=0.1, le=120.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)
class AudioValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["audio"] = "audio"
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: Optional[float] = 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)
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)
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels")
audio_source_id: str | None = Field(None, description="Mono audio source ID")
mode: str | None = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: float | None = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: float | None = Field(None, description="Temporal smoothing", ge=0.0, le=1.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)
auto_gain: bool | None = Field(None, description="Auto-normalize audio levels")
class AdaptiveTimeValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_time"] = "adaptive_time"
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
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)
schedule: list | None = Field(None, description="Time-of-day schedule")
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)
class AdaptiveSceneValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_scene"] = "adaptive_scene"
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
sensitivity: Optional[float] = 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)
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)
picture_source_id: str | None = Field(None, description="Picture source ID")
scene_behavior: str | None = Field(None, description="Scene behavior")
sensitivity: float | None = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: float | None = Field(None, description="Temporal smoothing", ge=0.0, le=1.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)
class DaylightValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["daylight"] = "daylight"
speed: Optional[float] = Field(None, description="Simulation speed", ge=0.1, le=120.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Geographic latitude", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(
None, description="Geographic longitude", ge=-180.0, le=180.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)
speed: float | None = Field(None, description="Simulation speed", ge=0.1, le=120.0)
use_real_time: bool | None = Field(None, description="Use wall-clock time")
latitude: float | None = Field(None, description="Geographic latitude", ge=-90.0, le=90.0)
longitude: float | None = Field(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)
class StaticColorValueSourceUpdate(_ValueSourceUpdateBase):
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):
source_type: Literal["animated_color"] = "animated_color"
colors: Optional[List[List[int]]] = Field(None, description="Color list [[R,G,B], ...]")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
easing: Optional[str] = Field(
colors: List[List[int]] | None = Field(None, description="Color list [[R,G,B], ...]")
speed: float | None = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
easing: str | None = Field(
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)"
)
class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase):
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):
source_type: Literal["ha_entity"] = "ha_entity"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
entity_id: Optional[str] = Field(None, description="HA entity ID")
attribute: Optional[str] = Field(None, description="Attribute name")
min_ha_value: Optional[float] = Field(None, description="Min HA value")
max_ha_value: Optional[float] = Field(None, description="Max HA value")
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
ha_source_id: str | None = Field(None, description="Home Assistant source ID")
entity_id: str | None = Field(None, description="HA entity ID")
attribute: str | None = Field(None, description="Attribute name")
min_ha_value: float | None = Field(None, description="Min HA value")
max_ha_value: float | None = Field(None, description="Max HA value")
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
class GradientMapValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["gradient_map"] = "gradient_map"
value_source_id: Optional[str] = Field(None, description="Input value source ID")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
easing: Optional[str] = Field(None, description="Interpolation mode")
value_source_id: str | None = Field(None, description="Input value source ID")
gradient_id: str | None = Field(None, description="Gradient entity ID")
easing: str | None = Field(None, description="Interpolation mode")
class CSSExtractValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["css_extract"] = "css_extract"
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
led_start: Optional[int] = Field(None, description="LED range start", ge=0)
led_end: Optional[int] = Field(None, description="LED range end")
color_strip_source_id: str | None = Field(None, description="Color strip source ID")
led_start: int | None = Field(None, description="LED range start", ge=0)
led_end: int | None = Field(None, description="LED range end")
class SystemMetricsValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["system_metrics"] = "system_metrics"
metric: Optional[str] = Field(None, description="System metric")
min_value: Optional[float] = Field(None, description="Min value")
max_value: Optional[float] = Field(None, description="Max value")
max_rate: Optional[float] = Field(None, description="Max rate bytes/sec")
disk_path: Optional[str] = Field(None, description="Disk path")
sensor_label: Optional[str] = Field(None, description="Sensor label")
poll_interval: Optional[float] = 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)
metric: str | None = Field(None, description="System metric")
min_value: float | None = Field(None, description="Min value")
max_value: float | None = Field(None, description="Max value")
max_rate: float | None = Field(None, description="Max rate bytes/sec")
disk_path: str | None = Field(None, description="Disk path")
sensor_label: str | None = Field(None, description="Sensor label")
poll_interval: float | None = Field(None, description="Poll interval", ge=0.1, le=60.0)
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
class HTTPValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["http"] = "http"
http_endpoint_id: Optional[str] = Field(None, description="HTTP endpoint ID")
json_path: Optional[str] = Field(None, description="Dot-path into the response")
interval_s: Optional[int] = Field(None, description="Polling cadence (seconds)", ge=1)
min_value: Optional[float] = Field(None, description="Raw value mapped to 0.0")
max_value: Optional[float] = Field(None, description="Raw value mapped to 1.0")
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
http_endpoint_id: str | None = Field(None, description="HTTP endpoint ID")
json_path: str | None = Field(None, description="Dot-path into the response")
interval_s: int | None = Field(None, description="Polling cadence (seconds)", ge=1)
min_value: float | None = Field(None, description="Raw value mapped to 0.0")
max_value: float | None = Field(None, description="Raw value mapped to 1.0")
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
ValueSourceUpdate = Annotated[
Union[
Annotated[StaticValueSourceUpdate, Tag("static")],
Annotated[AnimatedValueSourceUpdate, Tag("animated")],
Annotated[AudioValueSourceUpdate, Tag("audio")],
Annotated[AdaptiveTimeValueSourceUpdate, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceUpdate, Tag("adaptive_scene")],
Annotated[DaylightValueSourceUpdate, Tag("daylight")],
Annotated[StaticColorValueSourceUpdate, Tag("static_color")],
Annotated[AnimatedColorValueSourceUpdate, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceUpdate, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")],
Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")],
Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")],
Annotated[HTTPValueSourceUpdate, Tag("http")],
],
Annotated[StaticValueSourceUpdate, Tag("static")]
| Annotated[AnimatedValueSourceUpdate, Tag("animated")]
| Annotated[AudioValueSourceUpdate, Tag("audio")]
| Annotated[AdaptiveTimeValueSourceUpdate, Tag("adaptive_time")]
| Annotated[AdaptiveSceneValueSourceUpdate, Tag("adaptive_scene")]
| Annotated[DaylightValueSourceUpdate, Tag("daylight")]
| Annotated[StaticColorValueSourceUpdate, Tag("static_color")]
| Annotated[AnimatedColorValueSourceUpdate, Tag("animated_color")]
| Annotated[AdaptiveTimeColorValueSourceUpdate, Tag("adaptive_time_color")]
| Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")]
| Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")]
| Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")]
| Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")]
| Annotated[HTTPValueSourceUpdate, Tag("http")],
Discriminator("source_type"),
]
@@ -1,7 +1,7 @@
"""Weather source schemas (CRUD)."""
from datetime import datetime
from typing import Dict, List, Literal, Optional
from typing import Dict, List, Literal
from pydantic import BaseModel, Field
@@ -13,7 +13,7 @@ class WeatherSourceCreate(BaseModel):
provider: Literal["open_meteo"] = Field(
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(
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(
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")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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):
"""Request to update a weather source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
provider: Optional[Literal["open_meteo"]] = Field(None, description="Weather data provider")
provider_config: Optional[Dict] = Field(None, description="Provider-specific configuration")
latitude: Optional[float] = Field(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
provider: Literal["open_meteo"] | None = Field(None, description="Weather data provider")
provider_config: Dict | None = Field(None, description="Provider-specific configuration")
latitude: float | None = Field(
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
)
update_interval: Optional[int] = Field(
update_interval: int | None = Field(
None, description="API poll interval in seconds (60-3600)", ge=60, le=3600
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
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")
longitude: float = Field(description="Geographic longitude")
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")
icon: Optional[str] = Field(
icon: str | None = Field(
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.",
@@ -11,7 +11,7 @@ capture stream (WASAPI, sounddevice, etc.).
import threading
import time
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Tuple
from ledgrab.core.audio.analysis import (
AudioAnalysis,
@@ -49,7 +49,7 @@ class ManagedAudioStream:
engine_type: str,
device_index: int,
is_loopback: bool,
engine_config: Optional[Dict[str, Any]] = None,
engine_config: Dict[str, Any] | None = None,
):
self._engine_type = engine_type
self._device_index = device_index
@@ -57,9 +57,9 @@ class ManagedAudioStream:
self._engine_config = engine_config or {}
self._running = False
self._thread: Optional[threading.Thread] = None
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
self._latest: Optional[AudioAnalysis] = None
self._latest: AudioAnalysis | None = None
self._last_timing: dict = {}
def start(self) -> None:
@@ -90,7 +90,7 @@ class ManagedAudioStream:
f"device={self._device_index}"
)
def get_latest_analysis(self) -> Optional[AudioAnalysis]:
def get_latest_analysis(self) -> AudioAnalysis | None:
with self._lock:
return self._latest
@@ -98,7 +98,7 @@ class ManagedAudioStream:
return dict(self._last_timing)
def _capture_loop(self) -> None:
stream: Optional[AudioCaptureStreamBase] = None
stream: AudioCaptureStreamBase | None = None
try:
stream = AudioEngineRegistry.create_stream(
self._engine_type,
@@ -178,8 +178,8 @@ class AudioCaptureManager:
self,
device_index: int,
is_loopback: bool,
engine_type: Optional[str] = None,
engine_config: Optional[Dict[str, Any]] = None,
engine_type: str | None = None,
engine_config: Dict[str, Any] | None = None,
) -> ManagedAudioStream:
"""Get or create a ManagedAudioStream for the given device.
@@ -220,7 +220,7 @@ class AudioCaptureManager:
self,
device_index: int,
is_loopback: bool,
engine_type: Optional[str] = None,
engine_type: str | None = None,
) -> None:
"""Release a reference to a ManagedAudioStream."""
if engine_type is None:
+2 -2
View File
@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -83,7 +83,7 @@ class AudioCaptureStreamBase(ABC):
pass
@abstractmethod
def read_chunk(self) -> Optional[np.ndarray]:
def read_chunk(self) -> np.ndarray | None:
"""Read one chunk of raw audio data.
Returns:
+2 -2
View File
@@ -1,7 +1,7 @@
"""Demo audio engine — virtual audio devices with synthetic audio data."""
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -62,7 +62,7 @@ class DemoAudioCaptureStream(AudioCaptureStreamBase):
self._initialized = False
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:
return None
+2 -2
View File
@@ -1,6 +1,6 @@
"""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.config import is_demo_mode
@@ -82,7 +82,7 @@ class AudioEngineRegistry:
return available
@classmethod
def get_best_available_engine(cls) -> Optional[str]:
def get_best_available_engine(cls) -> str | None:
"""Get the highest-priority available engine type.
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.band_filter import apply_band_filter, compute_band_mask
# Preset frequency ranges
_PRESETS = {
"bass": (20.0, 250.0),
@@ -4,7 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from ledgrab.core.audio.analysis import AudioAnalysis
@@ -20,8 +20,8 @@ class AudioFilterOptionDef:
min_value: Any
max_value: Any
step: Any
choices: Optional[List[Dict[str, str]]] = None # for "select": [{value, label}]
max_length: Optional[int] = None # for "string" type
choices: List[Dict[str, str]] | None = None # for "select": [{value, label}]
max_length: int | None = None # for "string" type
def to_dict(self) -> dict:
d = {
@@ -1,6 +1,6 @@
"""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
@@ -79,7 +79,7 @@ class SounddeviceCaptureStream(AudioCaptureStreamBase):
self._sd_stream = None
self._initialized = False
def read_chunk(self) -> Optional[np.ndarray]:
def read_chunk(self) -> np.ndarray | None:
if self._sd_stream is None:
return None
try:
@@ -1,6 +1,6 @@
"""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
@@ -98,7 +98,7 @@ class WasapiCaptureStream(AudioCaptureStreamBase):
self._pa = None
self._initialized = False
def read_chunk(self) -> Optional[np.ndarray]:
def read_chunk(self) -> np.ndarray | None:
if self._stream is None:
return None
try:
@@ -109,7 +109,7 @@ class WasapiCaptureStream(AudioCaptureStreamBase):
return None
@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."""
try:
first_loopback = None
@@ -4,7 +4,7 @@ import asyncio
import re
from dataclasses import dataclass
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.storage.automation import (
@@ -38,11 +38,11 @@ class _RuleEvalContext:
"""
running_procs: Set[str]
topmost_proc: Optional[str]
topmost_proc: str | None
topmost_fullscreen: bool
fullscreen_procs: Set[str]
idle_seconds: Optional[float]
display_state: Optional[str]
idle_seconds: float | None
display_state: str | None
def _apply_operator(operator: str, extracted, expected: str) -> bool:
@@ -101,7 +101,7 @@ class AutomationEngine:
self._target_store = target_store
self._device_store = device_store
self._ha_manager = ha_manager
self._task: Optional[asyncio.Task] = None
self._task: asyncio.Task | None = None
self._eval_lock = asyncio.Lock()
# Runtime state (not persisted)
@@ -420,11 +420,11 @@ class AutomationEngine:
self,
automation: Automation,
running_procs: Set[str],
topmost_proc: Optional[str],
topmost_proc: str | None,
topmost_fullscreen: bool,
fullscreen_procs: Set[str],
idle_seconds: Optional[float],
display_state: Optional[str],
idle_seconds: float | None,
display_state: str | None,
) -> bool:
results = [
self._evaluate_rule(
@@ -453,11 +453,11 @@ class AutomationEngine:
self,
rule: Rule,
running_procs: Set[str],
topmost_proc: Optional[str],
topmost_proc: str | None,
topmost_fullscreen: bool,
fullscreen_procs: Set[str],
idle_seconds: Optional[float],
display_state: Optional[str],
idle_seconds: float | None,
display_state: str | None,
) -> bool:
ctx = _RuleEvalContext(
running_procs=running_procs,
@@ -531,14 +531,14 @@ class AutomationEngine:
return current >= start or current <= end
@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:
return False
is_idle = idle_seconds >= (rule.idle_minutes * 60)
return is_idle if rule.when_idle else not is_idle
@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:
return False
return display_state == rule.state
@@ -612,7 +612,7 @@ class AutomationEngine:
self,
rule: ApplicationRule,
running_procs: Set[str],
topmost_proc: Optional[str],
topmost_proc: str | None,
topmost_fullscreen: bool,
fullscreen_procs: Set[str],
) -> bool:
@@ -9,7 +9,7 @@ import ctypes
import os
import sys
import threading
from typing import Optional, Set
from typing import Set
from ledgrab.utils import get_logger
@@ -164,7 +164,7 @@ class PlatformDetector:
except Exception as 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."""
if not _IS_WINDOWS:
return None
@@ -172,7 +172,7 @@ class PlatformDetector:
# ---- 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).
Returns None if detection is unavailable.
@@ -4,7 +4,7 @@ import asyncio
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import List, Optional
from typing import List
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
@@ -33,8 +33,8 @@ class AutoBackupEngine:
):
self._backup_dir = Path(backup_dir)
self._db = db
self._task: Optional[asyncio.Task] = None
self._last_backup_time: Optional[datetime] = None
self._task: asyncio.Task | None = None
self._last_backup_time: datetime | None = None
self._settings = self._load_settings()
self._backup_dir.mkdir(parents=True, exist_ok=True)
@@ -1,13 +1,13 @@
"""Pixel processing utilities for color correction and manipulation."""
from typing import List, Tuple, Union
from typing import List, Tuple
import numpy as np
from ledgrab.utils import get_logger
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:
@@ -6,7 +6,7 @@ import colorsys
import logging
import sys
import threading
from typing import TYPE_CHECKING, Dict, List, Optional
from typing import TYPE_CHECKING, Dict, List
if TYPE_CHECKING:
import tkinter as tk
@@ -41,8 +41,8 @@ class OverlayWindow:
self.calibration = calibration
self.target_id = target_id
self.target_name = target_name or target_id
self._window: Optional[tk.Toplevel] = None
self._canvas: Optional[tk.Canvas] = None
self._window: tk.Toplevel | None = None
self._canvas: tk.Canvas | None = None
self.running = False
# ----- Lifecycle (must run in Tk thread) -----
@@ -352,8 +352,8 @@ class OverlayManager:
def __init__(self):
self._overlays: Dict[str, OverlayWindow] = {}
self._lock = threading.Lock()
self._tk_root: Optional[tk.Tk] = None
self._tk_thread: Optional[threading.Thread] = None
self._tk_root: tk.Tk | None = None
self._tk_thread: threading.Thread | None = None
self._tk_ready = threading.Event()
self._start_tk_thread()
@@ -386,7 +386,7 @@ class OverlayManager:
if self._tk_root is None:
raise RuntimeError("Tkinter root not available")
done = threading.Event()
exc_box: List[Optional[BaseException]] = [None]
exc_box: List[BaseException | None] = [None]
def wrapper():
try:
@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -70,7 +70,7 @@ class CaptureStream(ABC):
pass
@abstractmethod
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
"""Capture one frame from the bound display.
Returns:
@@ -2,7 +2,7 @@
import sys
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
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}")
return False
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -13,7 +13,7 @@ import platform
import sys
import threading
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
# ("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."""
if not isinstance(value, str):
return None
@@ -101,7 +101,7 @@ _BUILDINFO_LABELS: Dict[str, str] = {
"avfoundation": "AVFoundation",
}
_compiled_backends_cache: Optional[Set[str]] = None
_compiled_backends_cache: Set[str] | None = None
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)]
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."""
return _CV2_BACKENDS.get(backend_name)
@@ -307,7 +307,7 @@ def _get_camera_friendly_names() -> Dict[int, str]:
return {}
_camera_cache: Optional[List[Dict[str, Any]]] = None
_camera_cache: List[Dict[str, Any]] | None = None
_camera_cache_time: float = 0
_CAMERA_CACHE_TTL = 30.0 # seconds
@@ -428,7 +428,7 @@ class CameraCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
self._cap = None
self._cv2_index: Optional[int] = None
self._cv2_index: int | None = None
def initialize(self) -> None:
if self._initialized:
@@ -531,7 +531,7 @@ class CameraCaptureStream(CaptureStream):
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:
self.initialize()
@@ -1,7 +1,7 @@
"""Demo capture engine — virtual displays with animated test patterns."""
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -65,7 +65,7 @@ class DemoCaptureStream(CaptureStream):
self._initialized = False
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:
self.initialize()
@@ -2,7 +2,7 @@
import sys
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
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}")
return False
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -1,6 +1,6 @@
"""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.config import is_demo_mode
@@ -83,7 +83,7 @@ class EngineRegistry:
return available
@classmethod
def get_best_available_engine(cls) -> Optional[str]:
def get_best_available_engine(cls) -> str | None:
"""Get the highest-priority available engine type.
Returns:
@@ -29,7 +29,7 @@ logger = get_logger(__name__)
# ---------------------------------------------------------------------------
_frame_queue: queue.Queue["ScreenCapture"] = queue.Queue(maxsize=2)
_display_info: Optional[DisplayInfo] = None
_display_info: DisplayInfo | None = None
_active = False
_frames_received = 0
_frames_consumed = 0
@@ -141,7 +141,7 @@ class MediaProjectionCaptureStream(CaptureStream):
self._initialized = True
logger.info("MediaProjection capture stream initialized")
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
# Prefer fresh frames from the queue; fall back to the last
@@ -1,6 +1,6 @@
"""MSS-based screen capture engine (cross-platform)."""
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import mss
import numpy as np
@@ -41,7 +41,7 @@ class MSSCaptureStream(CaptureStream):
self._rgb_idx: int = 0
self._rgb_shape: tuple = (0, 0)
# 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:
try:
@@ -59,7 +59,7 @@ class MSSCaptureStream(CaptureStream):
self._prev_hash = None
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:
self.initialize()
@@ -35,7 +35,7 @@ logger = get_logger(__name__)
# ---------------------------------------------------------------------------
_frame_queue: queue.Queue["ScreenCapture"] = queue.Queue(maxsize=2)
_display_info: Optional[DisplayInfo] = None
_display_info: DisplayInfo | None = None
_active = False
_frames_received = 0
# screenrecord emits a full bitstream every frame (keyframes aside), so
@@ -123,7 +123,7 @@ class RootScreenrecordCaptureStream(CaptureStream):
self._initialized = True
logger.info("Root screenrecord capture stream initialized")
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
try:
@@ -14,7 +14,7 @@ video stream. No APK installation, no root.
"""
import threading
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -96,12 +96,12 @@ class ScrcpyClientCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
self._client: Optional["scrcpy.Client"] = None
self._latest_frame: Optional[ScreenCapture] = None
self._client: "scrcpy.Client" | None = None
self._latest_frame: ScreenCapture | None = None
self._frame_lock = threading.Lock()
self._frame_event = threading.Event()
self._client_thread: Optional[threading.Thread] = None
self._device_serial: Optional[str] = None
self._client_thread: threading.Thread | None = None
self._device_serial: str | None = None
def initialize(self) -> None:
if self._initialized:
@@ -189,7 +189,7 @@ class ScrcpyClientCaptureStream(CaptureStream):
)
self._frame_event.set()
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -18,7 +18,7 @@ import shutil
import subprocess
import threading
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -78,7 +78,7 @@ def _find_adb() -> str:
return "adb" # last resort — will fail with FileNotFoundError
_adb_path: Optional[str] = None
_adb_path: str | None = None
def _get_adb() -> str:
@@ -158,7 +158,7 @@ def _list_adb_devices() -> List[Dict[str, Any]]:
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."""
try:
result = subprocess.run(
@@ -190,12 +190,12 @@ class ScrcpyCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
self._capture_thread: Optional[threading.Thread] = None
self._latest_frame: Optional[ScreenCapture] = None
self._capture_thread: threading.Thread | None = None
self._latest_frame: ScreenCapture | None = None
self._frame_lock = threading.Lock()
self._frame_event = threading.Event()
self._running = False
self._device_serial: Optional[str] = None
self._device_serial: str | None = None
def initialize(self) -> None:
if self._initialized:
@@ -281,7 +281,7 @@ class ScrcpyCaptureStream(CaptureStream):
if poll_interval > 0:
time.sleep(poll_interval)
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -3,7 +3,7 @@
import gc
import sys
import threading
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -199,7 +199,7 @@ class WGCCaptureStream(CaptureStream):
gc.collect(0)
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:
self.initialize()
@@ -3,7 +3,7 @@
import asyncio
import concurrent.futures
from datetime import datetime, timezone
from typing import Optional, Tuple
from typing import Tuple
import numpy as np
@@ -41,7 +41,7 @@ def _build_adalight_header(led_count: int) -> bytes:
class AdalightClient(LEDClient):
"""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.
Args:
@@ -62,11 +62,11 @@ class AdalightClient(LEDClient):
# Pre-allocated wire buffer (header + RGB payload). Resized on the
# first frame and reused thereafter so the hot path performs no
# 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
# Scratch uint8 array used to coerce non-uint8 / non-contiguous input
# 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
# Dedicated single-worker executor for serial writes. Using
# ``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
# guarantees FIFO ordering of writes from this client even when
# 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:
"""Open serial port and wait for Arduino reset."""
@@ -245,7 +245,7 @@ class AdalightClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Check if the serial port exists without opening it.
@@ -10,7 +10,7 @@ so ``BLEClient`` can treat both backends identically.
from __future__ import annotations
import asyncio
from typing import List, Optional
from typing import List
from ledgrab.core.devices.ble_transport import DiscoveredBLEDevice
from ledgrab.utils import get_logger
@@ -49,7 +49,7 @@ async def android_ble_scan(timeout: float = 4.0) -> List[DiscoveredBLEDevice]:
continue
address, name, rssi_str = parts
try:
rssi: Optional[int] = int(rssi_str)
rssi: int | None = int(rssi_str)
except ValueError:
rssi = None
devices.append(DiscoveredBLEDevice(address=address, name=name or address, rssi=rssi))
@@ -80,7 +80,7 @@ class AndroidBLETransport:
self._address = address
self._write_char_uuid = write_char_uuid
self._write_with_response = write_with_response
self._handle: Optional[int] = None
self._handle: int | None = None
self._lock = asyncio.Lock()
@property
@@ -120,7 +120,7 @@ class AndroidBLETransport:
except Exception as 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.
Serialised through an internal lock — BLE stacks do not tolerate
@@ -8,7 +8,7 @@ optional ``@baud`` suffix).
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional, Tuple
from typing import List, Tuple
from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android
@@ -38,7 +38,7 @@ def _bridge():
class _UsbAddress:
vendor_id: int
product_id: int
serial: Optional[str]
serial: str | None
@classmethod
def parse(cls, device: str) -> "_UsbAddress":
@@ -59,7 +59,7 @@ class _UsbAddress:
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}"
return f"{base}:{serial}" if serial else base
@@ -101,7 +101,7 @@ class AndroidSerialTransport:
self._url = device
self._addr = _UsbAddress.parse(device)
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
def is_open(self) -> bool:
@@ -17,7 +17,7 @@ from __future__ import annotations
import asyncio
import time
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -92,7 +92,7 @@ class BLEClient(LEDClient):
write_with_response=self._protocol.write_with_response,
)
# 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":
try:
import binascii
@@ -104,7 +104,7 @@ class BLEClient(LEDClient):
except Exception as exc:
logger.warning("Invalid Govee AES key — ignoring: %s", exc)
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
# Throttle "not connected" warnings so the send loop doesn't spam logs
# at frame rate when a BLE connection drops silently.
@@ -161,12 +161,12 @@ class BLEClient(LEDClient):
return self._connected and self._transport.is_connected
@property
def device_led_count(self) -> Optional[int]:
def device_led_count(self) -> int | None:
return self._led_count or None
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""Average the strip to one color and write it — BLE protocols are whole-strip only."""
@@ -249,7 +249,7 @@ class BLEClient(LEDClient):
cls,
url: str,
http_client, # noqa: ARG003 — unused; kept for the LEDClient contract
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""BLE health isn't a passive check — a full GATT connect is the only signal.
@@ -2,7 +2,7 @@
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_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."""
from ledgrab.core.devices.led_client import get_provider
@@ -18,7 +18,7 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import List, Optional
from typing import List
from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android
@@ -65,7 +65,7 @@ class DiscoveredBLEDevice:
address: str
name: str
rssi: Optional[int]
rssi: int | None
service_uuids: tuple = ()
@@ -234,7 +234,7 @@ class BLETransport:
except Exception as 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.
Serialised through an internal lock — BLE stacks do not like
@@ -2,7 +2,7 @@
import asyncio
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -48,9 +48,9 @@ class ChromaClient(LEDClient):
self._base_url = url or CHROMA_SDK_URL
self._led_count = led_count
self._chroma_device_type = chroma_device_type
self._session_url: Optional[str] = None
self._session_url: str | None = None
self._connected = False
self._heartbeat_task: Optional[asyncio.Task] = None
self._heartbeat_task: asyncio.Task | None = None
self._http_client = None
async def connect(self) -> bool:
@@ -135,7 +135,7 @@ class ChromaClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self.is_connected or not self._http_client:
@@ -204,7 +204,7 @@ class ChromaClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Check if Chroma SDK is running."""
base = url or CHROMA_SDK_URL
@@ -3,7 +3,7 @@
import asyncio
import struct
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Tuple
import numpy as np
@@ -58,12 +58,12 @@ class DDPClient:
self._sequence = 0
self._buses: List[BusConfig] = []
# 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
# Pre-allocated send buffer (header + payload). Sized lazily on first
# send so we never allocate fresh bytes per frame on the hot path.
self._send_buf: Optional[bytearray] = None
self._send_view: Optional[memoryview] = None
self._send_buf: bytearray | None = None
self._send_view: memoryview | None = None
async def connect(self):
"""Establish UDP connection."""
@@ -13,7 +13,7 @@ from __future__ import annotations
import asyncio
import socket
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
from urllib.parse import urlparse
import numpy as np
@@ -69,7 +69,7 @@ class DDPLEDClient(LEDClient):
led_count: int = 0,
*,
rgbw: bool = False,
port: Optional[int] = None,
port: int | None = None,
destination_id: int = DEFAULT_DESTINATION_ID,
color_order: int = DEFAULT_COLOR_ORDER,
):
@@ -80,7 +80,7 @@ class DDPLEDClient(LEDClient):
self._rgbw = rgbw
self._destination_id = destination_id & 0xFF
self._color_order = color_order
self._ddp: Optional[DDPClient] = None
self._ddp: DDPClient | None = None
self._connected = False
@property
@@ -92,7 +92,7 @@ class DDPLEDClient(LEDClient):
return self._port
@property
def device_led_count(self) -> Optional[int]:
def device_led_count(self) -> int | None:
return self._led_count or None
@property
@@ -151,7 +151,7 @@ class DDPLEDClient(LEDClient):
# uint16 scratch avoids overflow; integer divide keeps everything in 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):
arr = pixels
else:
@@ -164,7 +164,7 @@ class DDPLEDClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self.is_connected:
@@ -176,7 +176,7 @@ class DDPLEDClient(LEDClient):
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> None:
if not self.is_connected or self._ddp is None:
@@ -189,7 +189,7 @@ class DDPLEDClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""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 typing import List, Literal, Optional, Union
from typing import List, Literal
@dataclass(frozen=True)
@@ -43,13 +43,13 @@ class DDPConfig(BaseDeviceConfig):
@dataclass(frozen=True)
class AdalightConfig(BaseDeviceConfig):
device_type: Literal["adalight"] = "adalight"
baud_rate: Optional[int] = None
baud_rate: int | None = None
@dataclass(frozen=True)
class AmbiLEDConfig(BaseDeviceConfig):
device_type: Literal["ambiled"] = "ambiled"
baud_rate: Optional[int] = None
baud_rate: int | None = None
@dataclass(frozen=True)
@@ -63,7 +63,7 @@ class DMXConfig(BaseDeviceConfig):
@dataclass(frozen=True)
class ESPNowConfig(BaseDeviceConfig):
device_type: Literal["espnow"] = "espnow"
baud_rate: Optional[int] = None
baud_rate: int | None = None
espnow_peer_mac: str = ""
espnow_channel: int = 1
@@ -217,29 +217,29 @@ class USBHIDConfig(BaseDeviceConfig):
device_type: Literal["usbhid"] = "usbhid"
DeviceConfig = Union[
WLEDConfig,
DDPConfig,
YeelightConfig,
WiZConfig,
LIFXConfig,
GoveeConfig,
OPCConfig,
NanoleafConfig,
AdalightConfig,
AmbiLEDConfig,
DMXConfig,
ESPNowConfig,
HueConfig,
SPIConfig,
ChromaConfig,
GameSenseConfig,
BLEConfig,
GroupConfig,
MQTTConfig,
WSConfig,
USBHIDConfig,
OpenRGBConfig,
MockConfig,
DemoConfig,
]
DeviceConfig = (
WLEDConfig
| DDPConfig
| YeelightConfig
| WiZConfig
| LIFXConfig
| GoveeConfig
| OPCConfig
| NanoleafConfig
| AdalightConfig
| AmbiLEDConfig
| DMXConfig
| ESPNowConfig
| HueConfig
| SPIConfig
| ChromaConfig
| GameSenseConfig
| BLEConfig
| GroupConfig
| MQTTConfig
| WSConfig
| USBHIDConfig
| OpenRGBConfig
| MockConfig
| DemoConfig
)
@@ -29,7 +29,7 @@ from __future__ import annotations
import asyncio
import time
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.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
@@ -74,9 +74,9 @@ class DiscoveryWatcher:
self._device_store = device_store
self._fire_event = fire_event
self._aiozc: Optional[AsyncZeroconf] = None
self._browser: Optional[AsyncServiceBrowser] = None
self._serial_task: Optional[asyncio.Task] = None
self._aiozc: AsyncZeroconf | None = None
self._browser: AsyncServiceBrowser | None = None
self._serial_task: asyncio.Task | None = None
self._running = False
self._started_at: float = 0.0
@@ -3,7 +3,7 @@
import asyncio
import struct
import uuid
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -40,7 +40,7 @@ class DMXClient(LEDClient):
def __init__(
self,
host: str,
port: Optional[int] = None,
port: int | None = None,
led_count: int = 1,
protocol: str = "artnet",
start_universe: int = 0,
@@ -123,7 +123,7 @@ class DMXClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self._transport:
@@ -133,7 +133,7 @@ class DMXClient(LEDClient):
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> None:
if not self._transport:
@@ -3,7 +3,7 @@
import asyncio
import struct
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -59,7 +59,7 @@ class ESPNowClient(LEDClient):
self,
url: str = "",
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_channel: int = 1,
**kwargs,
@@ -109,7 +109,7 @@ class ESPNowClient(LEDClient):
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> None:
if not self.is_connected:
@@ -129,7 +129,7 @@ class ESPNowClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self.is_connected:
@@ -146,7 +146,7 @@ class ESPNowClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Check if the serial port is available without opening it."""
port, _baud = parse_serial_url(url)
@@ -4,7 +4,7 @@ import json
import os
import platform
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -19,7 +19,7 @@ GAME_DISPLAY_NAME = "LedGrab"
EVENT_NAME = "PIXEL_DATA"
def _get_gamesense_address() -> Optional[str]:
def _get_gamesense_address() -> str | None:
"""Discover the SteelSeries GameSense address from coreProps.json."""
if platform.system() == "Windows":
props_path = os.path.join(
@@ -77,7 +77,7 @@ class GameSenseClient(LEDClient):
self._gs_device_type = gamesense_device_type
self._connected = False
self._http_client = None
self._base_url: Optional[str] = None
self._base_url: str | None = None
async def connect(self) -> bool:
import httpx
@@ -174,7 +174,7 @@ class GameSenseClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self.is_connected or not self._http_client:
@@ -227,7 +227,7 @@ class GameSenseClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Check if SteelSeries Engine is running."""
address = url.replace("gamesense://", "").strip() if url else None
@@ -21,7 +21,7 @@ import json
import socket
import time
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
from urllib.parse import urlparse
import numpy as np
@@ -105,8 +105,8 @@ class GoveeClient(LEDClient):
self._port = GOVEE_CONTROL_PORT
self._led_count = led_count
self._min_interval_s = max(0.0, min_interval_s)
self._transport: Optional[asyncio.DatagramTransport] = None
self._protocol: Optional[_GoveeProtocol] = None
self._transport: asyncio.DatagramTransport | None = None
self._protocol: _GoveeProtocol | None = None
self._connected = False
self._next_tx_at: float = 0.0
@@ -123,7 +123,7 @@ class GoveeClient(LEDClient):
return self._connected and self._transport is not None
@property
def device_led_count(self) -> Optional[int]:
def device_led_count(self) -> int | None:
return self._led_count or None
async def connect(self) -> bool:
@@ -185,7 +185,7 @@ class GoveeClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""Average the strip → colorwc with the resulting RGB."""
@@ -206,7 +206,7 @@ class GoveeClient(LEDClient):
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> None:
"""Synchronous variant for the hot loop."""
@@ -248,7 +248,7 @@ class GoveeClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""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.
Govee sends ``{"msg": {"cmd": "scan", "data": {"ip": ..., "device": ...,
@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, List, Optional, Tuple, Union
from typing import TYPE_CHECKING, List, Tuple
import numpy as np
@@ -82,14 +82,14 @@ class GroupLEDClient(LEDClient):
return self._connected and all(c.is_connected for c, _ in self._children)
@property
def device_led_count(self) -> Optional[int]:
def device_led_count(self) -> int | None:
if self._group_mode == "sequence":
return self._total_led_count
return None # independent mode uses user-specified led_count
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self._children:
@@ -150,7 +150,7 @@ class GroupLEDClient(LEDClient):
results = await asyncio.gather(*tasks, return_exceptions=True)
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."""
states = {}
for i, (client, _) in enumerate(self._children):
@@ -159,7 +159,7 @@ class GroupLEDClient(LEDClient):
states[i] = state
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."""
if not state:
return
@@ -4,7 +4,7 @@ import asyncio
import socket
import struct
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -79,7 +79,7 @@ class HueClient(LEDClient):
self._username = hue_username
self._client_key = hue_client_key
self._group_id = hue_entertainment_group_id
self._sock: Optional[socket.socket] = None
self._sock: socket.socket | None = None
self._connected = False
self._sequence = 0
self._dtls_sock = None
@@ -173,7 +173,7 @@ class HueClient(LEDClient):
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> None:
if not self._connected:
@@ -197,7 +197,7 @@ class HueClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self._connected:
@@ -210,7 +210,7 @@ class HueClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Check if the Hue bridge is reachable."""
bridge_ip = url.replace("hue://", "").rstrip("/")
+20 -20
View File
@@ -3,7 +3,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
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
@@ -17,7 +17,7 @@ class ProviderDeps:
"""Runtime dependencies injected into every provider.create_client() call."""
device_store: Optional["DeviceStore"] = None
mqtt_manager: Optional[object] = None # MQTTManager (avoid circular import)
mqtt_manager: object | None = None # MQTTManager (avoid circular import)
@dataclass
@@ -25,16 +25,16 @@ class DeviceHealth:
"""Health check result for an LED device."""
online: bool = False
latency_ms: Optional[float] = None
last_checked: Optional[datetime] = None
latency_ms: float | None = None
last_checked: datetime | None = None
# Device-reported metadata (populated by type-specific health check)
device_name: Optional[str] = None
device_version: Optional[str] = None
device_led_count: Optional[int] = None
device_rgbw: Optional[bool] = None
device_led_type: Optional[str] = None
device_fps: Optional[int] = None
error: Optional[str] = None
device_name: str | None = None
device_version: str | None = None
device_led_count: int | None = None
device_rgbw: bool | None = None
device_led_type: str | None = None
device_fps: int | None = None
error: str | None = None
class PairingNotReady(Exception):
@@ -55,12 +55,12 @@ class DiscoveredDevice:
device_type: str
ip: str
mac: str
led_count: Optional[int]
version: Optional[str]
led_count: int | None
version: str | None
# Optional provider-specific detected protocol identifier (e.g. BLE family
# like "sp110e" / "triones" / "zengge" / "govee"). Surfaced so the UI can
# 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):
@@ -99,7 +99,7 @@ class LEDClient(ABC):
@abstractmethod
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""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")
@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."""
return None
async def snapshot_device_state(self) -> Optional[dict]:
async def snapshot_device_state(self) -> dict | None:
"""Snapshot device state before streaming starts.
Override in subclasses that need to save/restore state around streaming.
@@ -139,7 +139,7 @@ class LEDClient(ABC):
"""
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.
Args:
@@ -152,7 +152,7 @@ class LEDClient(ABC):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Check device health without a full client connection.
@@ -309,7 +309,7 @@ async def check_device_health(
device_type: str,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Factory: dispatch health check to the right provider."""
return await get_provider(device_type).check_health(url, http_client, prev_health)
@@ -22,7 +22,7 @@ import socket
import struct
import time
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
from urllib.parse import urlparse
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)
def _parse_state_service_reply(raw: bytes) -> Optional[dict]:
def _parse_state_service_reply(raw: bytes) -> dict | None:
"""Parse a LIFX StateService (discovery) reply.
Returns ``{"mac": "aabbccddeeff", "service": int, "port": int}`` or
@@ -192,8 +192,8 @@ class LIFXClient(LEDClient):
self._port = port
self._led_count = led_count
self._min_interval_s = max(0.0, min_interval_s)
self._transport: Optional[asyncio.DatagramTransport] = None
self._protocol: Optional[_LIFXProtocol] = None
self._transport: asyncio.DatagramTransport | None = None
self._protocol: _LIFXProtocol | None = None
self._connected = False
self._next_tx_at: float = 0.0
self._sequence: int = 0
@@ -211,7 +211,7 @@ class LIFXClient(LEDClient):
return self._connected and self._transport is not None
@property
def device_led_count(self) -> Optional[int]:
def device_led_count(self) -> int | None:
return self._led_count or None
async def connect(self) -> bool:
@@ -257,7 +257,7 @@ class LIFXClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""Average the strip → HSBK → SetColor."""
@@ -279,7 +279,7 @@ class LIFXClient(LEDClient):
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> None:
"""Synchronous variant — same shape, runs on the hot loop."""
@@ -318,7 +318,7 @@ class LIFXClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Send GetService and wait briefly for a StateService reply."""
now = datetime.now(timezone.utc)
@@ -2,7 +2,7 @@
import asyncio
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -50,7 +50,7 @@ class MockClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self._connected:
@@ -64,7 +64,7 @@ class MockClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
return DeviceHealth(
online=True,
@@ -6,7 +6,7 @@ an ``mqtt_source_id`` so different devices can talk to different brokers.
"""
import json
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -81,7 +81,7 @@ class MQTTLEDClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if self._runtime is None:
@@ -111,7 +111,7 @@ class MQTTLEDClient(LEDClient):
prev_health=None,
*,
mqtt_manager=None,
mqtt_source_id: Optional[str] = None,
mqtt_source_id: str | None = None,
) -> DeviceHealth:
from datetime import datetime, timezone
@@ -24,7 +24,7 @@ from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
from urllib.parse import urlparse
import httpx
@@ -137,7 +137,7 @@ class NanoleafClient(LEDClient):
self._led_count = led_count
self._min_interval_s = max(0.0, min_interval_s)
self._request_timeout_s = request_timeout_s
self._http: Optional[httpx.AsyncClient] = None
self._http: httpx.AsyncClient | None = None
self._connected = False
self._next_tx_at: float = 0.0
@@ -156,7 +156,7 @@ class NanoleafClient(LEDClient):
return self._connected and self._http is not None
@property
def device_led_count(self) -> Optional[int]:
def device_led_count(self) -> int | None:
return self._led_count or None
def _state_url(self) -> str:
@@ -194,7 +194,7 @@ class NanoleafClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""Average the strip and PUT a single HSB state update."""
@@ -252,7 +252,7 @@ class NanoleafClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""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
@@ -21,7 +21,7 @@ from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
from urllib.parse import urlparse
import numpy as np
@@ -82,8 +82,8 @@ class OPCClient(LEDClient):
self._led_count = led_count
self._channel = channel & 0xFF
self._connect_timeout_s = connect_timeout_s
self._writer: Optional[asyncio.StreamWriter] = None
self._reader: Optional[asyncio.StreamReader] = None
self._writer: asyncio.StreamWriter | None = None
self._reader: asyncio.StreamReader | None = None
self._connected = False
@property
@@ -103,7 +103,7 @@ class OPCClient(LEDClient):
return self._connected and self._writer is not None
@property
def device_led_count(self) -> Optional[int]:
def device_led_count(self) -> int | None:
return self._led_count or None
async def connect(self) -> bool:
@@ -144,7 +144,7 @@ class OPCClient(LEDClient):
return np.zeros_like(pixels)
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):
arr = pixels
else:
@@ -159,7 +159,7 @@ class OPCClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self.is_connected:
@@ -175,7 +175,7 @@ class OPCClient(LEDClient):
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> None:
"""Synchronous hot-path write. Drain runs implicitly when the OS buffer
@@ -197,7 +197,7 @@ class OPCClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Open a TCP connection and close it. OPC has no protocol-level
ping; reachable TCP is the strongest signal we get."""
@@ -5,7 +5,7 @@ import socket
import struct
import threading
from datetime import datetime, timezone
from typing import Any, List, Optional, Tuple, Union
from typing import Any, List, Tuple
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.
Multiple zones are separated by ``+``.
"""
zones_str: Optional[str] = None
zones_str: str | None = None
if url.startswith("openrgb://"):
url = url[len("openrgb://") :]
@@ -87,16 +87,16 @@ class OpenRGBLEDClient(LEDClient):
self._client: Any = None # openrgb.OpenRGBClient
self._device: Any = None # openrgb.Device
self._connected = False
self._device_name: Optional[str] = None
self._device_led_count: Optional[int] = None
self._device_name: str | None = None
self._device_led_count: int | None = None
# Background sender thread — decouples processing loop from blocking TCP writes
self._send_lock = threading.Lock()
self._send_event = threading.Event()
self._send_pending: Optional[Tuple[np.ndarray, int]] = None # (pixels, brightness)
self._send_thread: Optional[threading.Thread] = None
self._send_pending: Tuple[np.ndarray, int] | None = None # (pixels, brightness)
self._send_thread: threading.Thread | None = None
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:
"""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
@property
def device_led_count(self) -> Optional[int]:
def device_led_count(self) -> int | None:
return self._device_led_count
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""Send pixel colors to the OpenRGB device (async wrapper)."""
@@ -232,7 +232,7 @@ class OpenRGBLEDClient(LEDClient):
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> None:
"""Non-blocking fire-and-forget send for the processing hot loop.
@@ -377,7 +377,7 @@ class OpenRGBLEDClient(LEDClient):
finally:
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."""
if self._device is None:
return None
@@ -387,7 +387,7 @@ class OpenRGBLEDClient(LEDClient):
logger.warning(f"Could not snapshot OpenRGB device state: {e}")
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."""
if not state or self._device is None:
return
@@ -404,7 +404,7 @@ class OpenRGBLEDClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""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 typing import List, Tuple, Union
from typing import List, Tuple
import numpy as np
def average_color(
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
) -> Tuple[int, int, int]:
"""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 dataclasses import dataclass
from typing import List, Optional, Protocol
from typing import List, Protocol
from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android
@@ -119,7 +119,7 @@ def port_exists(device: str) -> bool:
def open_transport(
url: str,
baud_rate: Optional[int] = None,
baud_rate: int | None = None,
timeout: float = 1.0,
) -> SerialTransport:
"""Construct an unopened transport for ``url``. Caller invokes ``open()``."""
@@ -2,7 +2,7 @@
import asyncio
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -155,7 +155,7 @@ class SPIClient(LEDClient):
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> None:
if not self._connected:
@@ -205,7 +205,7 @@ class SPIClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self._connected:
@@ -222,7 +222,7 @@ class SPIClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Check if the SPI/GPIO device is accessible."""
import platform
@@ -2,7 +2,7 @@
import asyncio
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
import numpy as np
@@ -90,7 +90,7 @@ class USBHIDClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self.is_connected:
@@ -144,7 +144,7 @@ class USBHIDClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Check if the HID device is present."""
try:
@@ -17,7 +17,7 @@ import json
import socket
import time
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
from urllib.parse import urlparse
import numpy as np
@@ -82,8 +82,8 @@ class WiZClient(LEDClient):
self._port = port
self._led_count = led_count
self._min_interval_s = max(0.0, min_interval_s)
self._transport: Optional[asyncio.DatagramTransport] = None
self._protocol: Optional[_WiZProtocol] = None
self._transport: asyncio.DatagramTransport | None = None
self._protocol: _WiZProtocol | None = None
self._connected = False
self._next_tx_at: float = 0.0
@@ -100,7 +100,7 @@ class WiZClient(LEDClient):
return self._connected and self._transport is not None
@property
def device_led_count(self) -> Optional[int]:
def device_led_count(self) -> int | None:
return self._led_count or None
async def connect(self) -> bool:
@@ -137,7 +137,7 @@ class WiZClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""Average the pixel strip to one color and push ``setPilot``."""
@@ -161,7 +161,7 @@ class WiZClient(LEDClient):
def send_pixels_fast(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> None:
"""Synchronous variant for the hot path. Same shape as send_pixels."""
@@ -205,7 +205,7 @@ class WiZClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Send a getPilot and wait briefly for any reply on a one-shot socket."""
now = datetime.now(timezone.utc)
@@ -4,7 +4,7 @@ import asyncio
import time
from dataclasses import dataclass, field
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
import httpx
@@ -106,10 +106,10 @@ class WLEDClient(LEDClient):
parsed = urlparse(self.url)
self.host = parsed.hostname or parsed.netloc.split(":")[0]
self._client: Optional[httpx.AsyncClient] = None
self._ddp_client: Optional[DDPClient] = None
self._client: httpx.AsyncClient | None = None
self._ddp_client: DDPClient | None = None
self._connected = False
self._pre_connect_state: Optional[dict] = None
self._pre_connect_state: dict | None = None
async def connect(self) -> bool:
"""Establish connection to WLED device.
@@ -208,7 +208,7 @@ class WLEDClient(LEDClient):
self,
method: str,
endpoint: str,
json_data: Optional[Dict[str, Any]] = None,
json_data: Dict[str, Any] | None = None,
retry: bool = True,
) -> Dict[str, Any]:
"""Make HTTP request to WLED device with retry logic.
@@ -498,7 +498,7 @@ class WLEDClient(LEDClient):
# ===== 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).
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}")
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."""
if not state:
return
@@ -541,7 +541,7 @@ class WLEDClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""WLED health check via GET /json/info (+ /json/cfg for LED type)."""
url = url.rstrip("/")
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import json
import time
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
from typing import TYPE_CHECKING, Dict, List, Tuple
import httpx
from zeroconf import ServiceStateChange
@@ -47,7 +47,7 @@ class WLEDDeviceProvider(LEDDeviceProvider):
"""Provider for WLED LED controllers."""
def __init__(self):
self._http_client: Optional[httpx.AsyncClient] = None
self._http_client: httpx.AsyncClient | None = None
self._client_lock = asyncio.Lock()
# Per-base-URL state cache: base -> (expires_at, json_state_dict)
self._state_cache: Dict[str, tuple] = {}
@@ -186,7 +186,7 @@ class WLEDDeviceProvider(LEDDeviceProvider):
async def _enrich_device(
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.
Reuses the shared HTTP client so discovery probes share connection-pool state.
+3 -3
View File
@@ -2,7 +2,7 @@
import asyncio
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple, Union
from typing import Dict, List, Tuple
import numpy as np
@@ -85,7 +85,7 @@ class WSLEDClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
if not self._connected:
@@ -123,7 +123,7 @@ class WSLEDClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
return DeviceHealth(
online=True,
@@ -22,7 +22,7 @@ import json
import socket
import time
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
from typing import List, Tuple
from urllib.parse import urlparse
import numpy as np
@@ -77,8 +77,8 @@ class YeelightClient(LEDClient):
self._led_count = led_count
self._min_interval_s = max(0.0, min_interval_s)
self._connect_timeout_s = connect_timeout_s
self._reader: Optional[asyncio.StreamReader] = None
self._writer: Optional[asyncio.StreamWriter] = None
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._connected = False
self._next_tx_at: float = 0.0
self._req_id: int = 0
@@ -93,7 +93,7 @@ class YeelightClient(LEDClient):
return self._connected and self._writer is not None
@property
def device_led_count(self) -> Optional[int]:
def device_led_count(self) -> int | None:
return self._led_count or None
async def connect(self) -> bool:
@@ -141,7 +141,7 @@ class YeelightClient(LEDClient):
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
pixels: List[Tuple[int, int, int]] | np.ndarray,
brightness: int = 255,
) -> bool:
"""Average the pixel strip to one color and ``set_rgb``.
@@ -185,7 +185,7 @@ class YeelightClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Health check: open the TCP socket to the bulb and close it."""
now = datetime.now(timezone.utc)
@@ -230,7 +230,7 @@ _DISCOVER_REQUEST = (
).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.
Returns ``None`` when the payload doesn't look like a Yeelight reply
+2 -2
View File
@@ -1,6 +1,6 @@
"""Auto-crop postprocessing filter."""
from typing import List, Optional
from typing import List
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)
min_bar_size = self.options.get("min_bar_size", 20)
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 dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from typing import TYPE_CHECKING, Any, Dict, List
import numpy as np
@@ -23,8 +23,8 @@ class FilterOptionDef:
min_value: Any
max_value: Any
step: Any
choices: Optional[List[Dict[str, str]]] = None # for "select": [{value, label}]
max_length: Optional[int] = None # for "string" type
choices: List[Dict[str, str]] | None = None # for "select": [{value, label}]
max_length: int | None = None # for "string" type
def to_dict(self) -> dict:
d = {
@@ -76,7 +76,7 @@ class PostprocessingFilter(ABC):
...
@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.
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.
Default implementation reshapes to (1, N, 3), calls process_image
@@ -1,6 +1,6 @@
"""Brightness postprocessing filter."""
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
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:
return None
image[:] = self._lut[image]
@@ -1,7 +1,7 @@
"""Color correction postprocessing filter."""
import math
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
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:
return None
image[:] = self._lut[image]
+2 -2
View File
@@ -1,6 +1,6 @@
"""Contrast postprocessing filter."""
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
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:
return None
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.
"""
from typing import List, Optional
from typing import List
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.
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.
return None
@@ -1,6 +1,6 @@
"""Downscaler postprocessing filter."""
from typing import List, Optional
from typing import List
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"]
if factor >= 1.0:
return None
@@ -5,7 +5,7 @@ instantiated at runtime: ``LiveStreamManager`` expands it into the
referenced template's filters when building the processing pipeline.
"""
from typing import List, Optional
from typing import List
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.
return None
+2 -2
View File
@@ -1,6 +1,6 @@
"""Flip postprocessing filter."""
from typing import List, Optional
from typing import List
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)
v = self.options.get("vertical", False)
if not h and not v:
@@ -1,7 +1,7 @@
"""Frame interpolation postprocessing filter."""
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -38,21 +38,21 @@ class FrameInterpolationFilter(PostprocessingFilter):
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
self._frame_a: Optional[np.ndarray] = None # frame N-1
self._frame_b: Optional[np.ndarray] = None # frame N (latest source)
self._frame_a: np.ndarray | None = None # frame N-1
self._frame_b: np.ndarray | None = None # frame N (latest source)
self._time_a: 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
self._u16_a: Optional[np.ndarray] = None
self._u16_b: Optional[np.ndarray] = None
self._blend_shape: Optional[tuple] = None
self._u16_a: np.ndarray | None = None
self._u16_b: np.ndarray | None = None
self._blend_shape: tuple | None = None
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
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.
Returns:
@@ -61,11 +61,11 @@ class FrameInterpolationFilter(PostprocessingFilter):
"""
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."""
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."""
now = time.perf_counter()

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