diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 14fd157..6fb626b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/server/pyproject.toml b/server/pyproject.toml index a688bc3..e037b4b 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -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"] diff --git a/server/src/ledgrab/android_entry.py b/server/src/ledgrab/android_entry.py index fab1d14..9efaa0c 100644 --- a/server/src/ledgrab/android_entry.py +++ b/server/src/ledgrab/android_entry.py @@ -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: diff --git a/server/src/ledgrab/api/routes/_preview_helpers.py b/server/src/ledgrab/api/routes/_preview_helpers.py index 845f410..e8aeed3 100644 --- a/server/src/ledgrab/api/routes/_preview_helpers.py +++ b/server/src/ledgrab/api/routes/_preview_helpers.py @@ -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. diff --git a/server/src/ledgrab/api/routes/audio_sources.py b/server/src/ledgrab/api/routes/audio_sources.py index 5dd5436..704900e 100644 --- a/server/src/ledgrab/api/routes/audio_sources.py +++ b/server/src/ledgrab/api/routes/audio_sources.py @@ -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), diff --git a/server/src/ledgrab/api/routes/output_targets.py b/server/src/ledgrab/api/routes/output_targets.py index 97bd9b3..e3de57c 100644 --- a/server/src/ledgrab/api/routes/output_targets.py +++ b/server/src/ledgrab/api/routes/output_targets.py @@ -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 diff --git a/server/src/ledgrab/api/routes/system.py b/server/src/ledgrab/api/routes/system.py index 1a55397..6534d00 100644 --- a/server/src/ledgrab/api/routes/system.py +++ b/server/src/ledgrab/api/routes/system.py @@ -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. diff --git a/server/src/ledgrab/api/routes/value_sources.py b/server/src/ledgrab/api/routes/value_sources.py index b305fcb..01b6f4e 100644 --- a/server/src/ledgrab/api/routes/value_sources.py +++ b/server/src/ledgrab/api/routes/value_sources.py @@ -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", ), diff --git a/server/src/ledgrab/api/schemas/assets.py b/server/src/ledgrab/api/schemas/assets.py index 8304b82..918704a 100644 --- a/server/src/ledgrab/api/schemas/assets.py +++ b/server/src/ledgrab/api/schemas/assets.py @@ -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.", diff --git a/server/src/ledgrab/api/schemas/audio_processing.py b/server/src/ledgrab/api/schemas/audio_processing.py index 8775eae..086932c 100644 --- a/server/src/ledgrab/api/schemas/audio_processing.py +++ b/server/src/ledgrab/api/schemas/audio_processing.py @@ -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.", diff --git a/server/src/ledgrab/api/schemas/audio_sources.py b/server/src/ledgrab/api/schemas/audio_sources.py index 6718921..1a59028 100644 --- a/server/src/ledgrab/api/schemas/audio_sources.py +++ b/server/src/ledgrab/api/schemas/audio_sources.py @@ -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"), ] diff --git a/server/src/ledgrab/api/schemas/audio_templates.py b/server/src/ledgrab/api/schemas/audio_templates.py index aad07a8..6a45897 100644 --- a/server/src/ledgrab/api/schemas/audio_templates.py +++ b/server/src/ledgrab/api/schemas/audio_templates.py @@ -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.", diff --git a/server/src/ledgrab/api/schemas/automations.py b/server/src/ledgrab/api/schemas/automations.py index b840735..cfc8b93 100644 --- a/server/src/ledgrab/api/schemas/automations.py +++ b/server/src/ledgrab/api/schemas/automations.py @@ -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.", diff --git a/server/src/ledgrab/api/schemas/color_strip_processing.py b/server/src/ledgrab/api/schemas/color_strip_processing.py index 16a09fd..8007a4e 100644 --- a/server/src/ledgrab/api/schemas/color_strip_processing.py +++ b/server/src/ledgrab/api/schemas/color_strip_processing.py @@ -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.", diff --git a/server/src/ledgrab/api/schemas/color_strip_sources.py b/server/src/ledgrab/api/schemas/color_strip_sources.py index bd4446f..78df0a8 100644 --- a/server/src/ledgrab/api/schemas/color_strip_sources.py +++ b/server/src/ledgrab/api/schemas/color_strip_sources.py @@ -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): diff --git a/server/src/ledgrab/api/schemas/common.py b/server/src/ledgrab/api/schemas/common.py index c5c0c2c..04ebf45 100644 --- a/server/src/ledgrab/api/schemas/common.py +++ b/server/src/ledgrab/api/schemas/common.py @@ -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") diff --git a/server/src/ledgrab/api/schemas/devices.py b/server/src/ledgrab/api/schemas/devices.py index b0e9e9a..3264348 100644 --- a/server/src/ledgrab/api/schemas/devices.py +++ b/server/src/ledgrab/api/schemas/devices.py @@ -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( diff --git a/server/src/ledgrab/api/schemas/filters.py b/server/src/ledgrab/api/schemas/filters.py index df9e97d..8b94aef 100644 --- a/server/src/ledgrab/api/schemas/filters.py +++ b/server/src/ledgrab/api/schemas/filters.py @@ -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" ) diff --git a/server/src/ledgrab/api/schemas/game_integration.py b/server/src/ledgrab/api/schemas/game_integration.py index 425b64a..c55d9f1 100644 --- a/server/src/ledgrab/api/schemas/game_integration.py +++ b/server/src/ledgrab/api/schemas/game_integration.py @@ -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" diff --git a/server/src/ledgrab/api/schemas/gradients.py b/server/src/ledgrab/api/schemas/gradients.py index f4b27e7..bbef946 100644 --- a/server/src/ledgrab/api/schemas/gradients.py +++ b/server/src/ledgrab/api/schemas/gradients.py @@ -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.", diff --git a/server/src/ledgrab/api/schemas/home_assistant.py b/server/src/ledgrab/api/schemas/home_assistant.py index aac74c5..63966df 100644 --- a/server/src/ledgrab/api/schemas/home_assistant.py +++ b/server/src/ledgrab/api/schemas/home_assistant.py @@ -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): diff --git a/server/src/ledgrab/api/schemas/http_endpoints.py b/server/src/ledgrab/api/schemas/http_endpoints.py index 38ae1f4..b8cd363 100644 --- a/server/src/ledgrab/api/schemas/http_endpoints.py +++ b/server/src/ledgrab/api/schemas/http_endpoints.py @@ -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 diff --git a/server/src/ledgrab/api/schemas/mqtt.py b/server/src/ledgrab/api/schemas/mqtt.py index 0b2b2f6..268135a 100644 --- a/server/src/ledgrab/api/schemas/mqtt.py +++ b/server/src/ledgrab/api/schemas/mqtt.py @@ -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): diff --git a/server/src/ledgrab/api/schemas/output_targets.py b/server/src/ledgrab/api/schemas/output_targets.py index 8049b50..4d3a34c 100644 --- a/server/src/ledgrab/api/schemas/output_targets.py +++ b/server/src/ledgrab/api/schemas/output_targets.py @@ -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): diff --git a/server/src/ledgrab/api/schemas/pattern_templates.py b/server/src/ledgrab/api/schemas/pattern_templates.py index 32c5cef..0d137d6 100644 --- a/server/src/ledgrab/api/schemas/pattern_templates.py +++ b/server/src/ledgrab/api/schemas/pattern_templates.py @@ -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.", diff --git a/server/src/ledgrab/api/schemas/picture_sources.py b/server/src/ledgrab/api/schemas/picture_sources.py index cc46425..1ea3a49 100644 --- a/server/src/ledgrab/api/schemas/picture_sources.py +++ b/server/src/ledgrab/api/schemas/picture_sources.py @@ -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") diff --git a/server/src/ledgrab/api/schemas/postprocessing.py b/server/src/ledgrab/api/schemas/postprocessing.py index 3b36883..0b4e3d3 100644 --- a/server/src/ledgrab/api/schemas/postprocessing.py +++ b/server/src/ledgrab/api/schemas/postprocessing.py @@ -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.", diff --git a/server/src/ledgrab/api/schemas/scene_presets.py b/server/src/ledgrab/api/schemas/scene_presets.py index 8f33166..3e6b908 100644 --- a/server/src/ledgrab/api/schemas/scene_presets.py +++ b/server/src/ledgrab/api/schemas/scene_presets.py @@ -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.", diff --git a/server/src/ledgrab/api/schemas/sync_clocks.py b/server/src/ledgrab/api/schemas/sync_clocks.py index 7ca6745..9f147a8 100644 --- a/server/src/ledgrab/api/schemas/sync_clocks.py +++ b/server/src/ledgrab/api/schemas/sync_clocks.py @@ -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.1–10.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.1–10.0)", ge=0.1, le=10.0) - description: Optional[str] = Field(None, description="Optional description", max_length=500) - tags: Optional[List[str]] = None - 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.1–10.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.", diff --git a/server/src/ledgrab/api/schemas/templates.py b/server/src/ledgrab/api/schemas/templates.py index 279fcda..393861e 100644 --- a/server/src/ledgrab/api/schemas/templates.py +++ b/server/src/ledgrab/api/schemas/templates.py @@ -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." ) diff --git a/server/src/ledgrab/api/schemas/value_sources.py b/server/src/ledgrab/api/schemas/value_sources.py index 470d8b9..94e02a7 100644 --- a/server/src/ledgrab/api/schemas/value_sources.py +++ b/server/src/ledgrab/api/schemas/value_sources.py @@ -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"), ] diff --git a/server/src/ledgrab/api/schemas/weather_sources.py b/server/src/ledgrab/api/schemas/weather_sources.py index e409007..269f753 100644 --- a/server/src/ledgrab/api/schemas/weather_sources.py +++ b/server/src/ledgrab/api/schemas/weather_sources.py @@ -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.", diff --git a/server/src/ledgrab/core/audio/audio_capture.py b/server/src/ledgrab/core/audio/audio_capture.py index deae46c..cd91a6e 100644 --- a/server/src/ledgrab/core/audio/audio_capture.py +++ b/server/src/ledgrab/core/audio/audio_capture.py @@ -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: diff --git a/server/src/ledgrab/core/audio/base.py b/server/src/ledgrab/core/audio/base.py index f6c0506..14a4b4c 100644 --- a/server/src/ledgrab/core/audio/base.py +++ b/server/src/ledgrab/core/audio/base.py @@ -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: diff --git a/server/src/ledgrab/core/audio/demo_engine.py b/server/src/ledgrab/core/audio/demo_engine.py index 4fbda93..be3b824 100644 --- a/server/src/ledgrab/core/audio/demo_engine.py +++ b/server/src/ledgrab/core/audio/demo_engine.py @@ -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 diff --git a/server/src/ledgrab/core/audio/factory.py b/server/src/ledgrab/core/audio/factory.py index 7b98016..0f5a20c 100644 --- a/server/src/ledgrab/core/audio/factory.py +++ b/server/src/ledgrab/core/audio/factory.py @@ -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: diff --git a/server/src/ledgrab/core/audio/filters/band_extract.py b/server/src/ledgrab/core/audio/filters/band_extract.py index 2189dbe..03eca4b 100644 --- a/server/src/ledgrab/core/audio/filters/band_extract.py +++ b/server/src/ledgrab/core/audio/filters/band_extract.py @@ -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), diff --git a/server/src/ledgrab/core/audio/filters/base.py b/server/src/ledgrab/core/audio/filters/base.py index d2029d0..68adfe7 100644 --- a/server/src/ledgrab/core/audio/filters/base.py +++ b/server/src/ledgrab/core/audio/filters/base.py @@ -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 = { diff --git a/server/src/ledgrab/core/audio/sounddevice_engine.py b/server/src/ledgrab/core/audio/sounddevice_engine.py index c743b15..d433830 100644 --- a/server/src/ledgrab/core/audio/sounddevice_engine.py +++ b/server/src/ledgrab/core/audio/sounddevice_engine.py @@ -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: diff --git a/server/src/ledgrab/core/audio/wasapi_engine.py b/server/src/ledgrab/core/audio/wasapi_engine.py index 49ed141..910b052 100644 --- a/server/src/ledgrab/core/audio/wasapi_engine.py +++ b/server/src/ledgrab/core/audio/wasapi_engine.py @@ -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 diff --git a/server/src/ledgrab/core/automations/automation_engine.py b/server/src/ledgrab/core/automations/automation_engine.py index b961c28..2769eda 100644 --- a/server/src/ledgrab/core/automations/automation_engine.py +++ b/server/src/ledgrab/core/automations/automation_engine.py @@ -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: diff --git a/server/src/ledgrab/core/automations/platform_detector.py b/server/src/ledgrab/core/automations/platform_detector.py index 5a0760e..c4f12ab 100644 --- a/server/src/ledgrab/core/automations/platform_detector.py +++ b/server/src/ledgrab/core/automations/platform_detector.py @@ -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. diff --git a/server/src/ledgrab/core/backup/auto_backup.py b/server/src/ledgrab/core/backup/auto_backup.py index b19ef2c..682b186 100644 --- a/server/src/ledgrab/core/backup/auto_backup.py +++ b/server/src/ledgrab/core/backup/auto_backup.py @@ -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) diff --git a/server/src/ledgrab/core/capture/pixel_processor.py b/server/src/ledgrab/core/capture/pixel_processor.py index c41faf6..ddbb4ea 100644 --- a/server/src/ledgrab/core/capture/pixel_processor.py +++ b/server/src/ledgrab/core/capture/pixel_processor.py @@ -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: diff --git a/server/src/ledgrab/core/capture/screen_overlay.py b/server/src/ledgrab/core/capture/screen_overlay.py index 699dcdc..943aea4 100644 --- a/server/src/ledgrab/core/capture/screen_overlay.py +++ b/server/src/ledgrab/core/capture/screen_overlay.py @@ -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: diff --git a/server/src/ledgrab/core/capture_engines/base.py b/server/src/ledgrab/core/capture_engines/base.py index 14e6ee1..23d1390 100644 --- a/server/src/ledgrab/core/capture_engines/base.py +++ b/server/src/ledgrab/core/capture_engines/base.py @@ -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: diff --git a/server/src/ledgrab/core/capture_engines/bettercam_engine.py b/server/src/ledgrab/core/capture_engines/bettercam_engine.py index fff4ae2..71df506 100644 --- a/server/src/ledgrab/core/capture_engines/bettercam_engine.py +++ b/server/src/ledgrab/core/capture_engines/bettercam_engine.py @@ -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() diff --git a/server/src/ledgrab/core/capture_engines/camera_engine.py b/server/src/ledgrab/core/capture_engines/camera_engine.py index 290caf2..6ca16f5 100644 --- a/server/src/ledgrab/core/capture_engines/camera_engine.py +++ b/server/src/ledgrab/core/capture_engines/camera_engine.py @@ -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() diff --git a/server/src/ledgrab/core/capture_engines/demo_engine.py b/server/src/ledgrab/core/capture_engines/demo_engine.py index ec7a5c8..1685635 100644 --- a/server/src/ledgrab/core/capture_engines/demo_engine.py +++ b/server/src/ledgrab/core/capture_engines/demo_engine.py @@ -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() diff --git a/server/src/ledgrab/core/capture_engines/dxcam_engine.py b/server/src/ledgrab/core/capture_engines/dxcam_engine.py index 14be006..2ca3c8a 100644 --- a/server/src/ledgrab/core/capture_engines/dxcam_engine.py +++ b/server/src/ledgrab/core/capture_engines/dxcam_engine.py @@ -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() diff --git a/server/src/ledgrab/core/capture_engines/factory.py b/server/src/ledgrab/core/capture_engines/factory.py index de32652..a8caaa5 100644 --- a/server/src/ledgrab/core/capture_engines/factory.py +++ b/server/src/ledgrab/core/capture_engines/factory.py @@ -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: diff --git a/server/src/ledgrab/core/capture_engines/mediaprojection_engine.py b/server/src/ledgrab/core/capture_engines/mediaprojection_engine.py index 079312d..b5116b1 100644 --- a/server/src/ledgrab/core/capture_engines/mediaprojection_engine.py +++ b/server/src/ledgrab/core/capture_engines/mediaprojection_engine.py @@ -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 diff --git a/server/src/ledgrab/core/capture_engines/mss_engine.py b/server/src/ledgrab/core/capture_engines/mss_engine.py index 419e760..06c04ec 100644 --- a/server/src/ledgrab/core/capture_engines/mss_engine.py +++ b/server/src/ledgrab/core/capture_engines/mss_engine.py @@ -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() diff --git a/server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py b/server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py index ca1c42f..de6b0ed 100644 --- a/server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py +++ b/server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py @@ -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: diff --git a/server/src/ledgrab/core/capture_engines/scrcpy_client_engine.py b/server/src/ledgrab/core/capture_engines/scrcpy_client_engine.py index c6c7565..1dbdef6 100644 --- a/server/src/ledgrab/core/capture_engines/scrcpy_client_engine.py +++ b/server/src/ledgrab/core/capture_engines/scrcpy_client_engine.py @@ -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() diff --git a/server/src/ledgrab/core/capture_engines/scrcpy_engine.py b/server/src/ledgrab/core/capture_engines/scrcpy_engine.py index 881641b..21f2945 100644 --- a/server/src/ledgrab/core/capture_engines/scrcpy_engine.py +++ b/server/src/ledgrab/core/capture_engines/scrcpy_engine.py @@ -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() diff --git a/server/src/ledgrab/core/capture_engines/wgc_engine.py b/server/src/ledgrab/core/capture_engines/wgc_engine.py index a5d850e..670d1a6 100644 --- a/server/src/ledgrab/core/capture_engines/wgc_engine.py +++ b/server/src/ledgrab/core/capture_engines/wgc_engine.py @@ -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() diff --git a/server/src/ledgrab/core/devices/adalight_client.py b/server/src/ledgrab/core/devices/adalight_client.py index edb89fd..21245d3 100644 --- a/server/src/ledgrab/core/devices/adalight_client.py +++ b/server/src/ledgrab/core/devices/adalight_client.py @@ -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 (~5–10 µ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. diff --git a/server/src/ledgrab/core/devices/android_ble_transport.py b/server/src/ledgrab/core/devices/android_ble_transport.py index 6e6705f..6c2a059 100644 --- a/server/src/ledgrab/core/devices/android_ble_transport.py +++ b/server/src/ledgrab/core/devices/android_ble_transport.py @@ -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 diff --git a/server/src/ledgrab/core/devices/android_serial_transport.py b/server/src/ledgrab/core/devices/android_serial_transport.py index d0f6775..d2fe44a 100644 --- a/server/src/ledgrab/core/devices/android_serial_transport.py +++ b/server/src/ledgrab/core/devices/android_serial_transport.py @@ -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: diff --git a/server/src/ledgrab/core/devices/ble_client.py b/server/src/ledgrab/core/devices/ble_client.py index 729a32c..2369f21 100644 --- a/server/src/ledgrab/core/devices/ble_client.py +++ b/server/src/ledgrab/core/devices/ble_client.py @@ -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. diff --git a/server/src/ledgrab/core/devices/ble_provider.py b/server/src/ledgrab/core/devices/ble_provider.py index 92f0d84..1ca794d 100644 --- a/server/src/ledgrab/core/devices/ble_provider.py +++ b/server/src/ledgrab/core/devices/ble_provider.py @@ -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 diff --git a/server/src/ledgrab/core/devices/ble_transport.py b/server/src/ledgrab/core/devices/ble_transport.py index bbdacf2..d03211a 100644 --- a/server/src/ledgrab/core/devices/ble_transport.py +++ b/server/src/ledgrab/core/devices/ble_transport.py @@ -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 diff --git a/server/src/ledgrab/core/devices/chroma_client.py b/server/src/ledgrab/core/devices/chroma_client.py index da2bc80..98f28a8 100644 --- a/server/src/ledgrab/core/devices/chroma_client.py +++ b/server/src/ledgrab/core/devices/chroma_client.py @@ -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 diff --git a/server/src/ledgrab/core/devices/ddp_client.py b/server/src/ledgrab/core/devices/ddp_client.py index b96a4bb..7c3ac41 100644 --- a/server/src/ledgrab/core/devices/ddp_client.py +++ b/server/src/ledgrab/core/devices/ddp_client.py @@ -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.""" diff --git a/server/src/ledgrab/core/devices/ddp_led_client.py b/server/src/ledgrab/core/devices/ddp_led_client.py index 3f9b2e3..e4f8e2e 100644 --- a/server/src/ledgrab/core/devices/ddp_led_client.py +++ b/server/src/ledgrab/core/devices/ddp_led_client.py @@ -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. diff --git a/server/src/ledgrab/core/devices/device_config.py b/server/src/ledgrab/core/devices/device_config.py index 1d25e20..8011d5f 100644 --- a/server/src/ledgrab/core/devices/device_config.py +++ b/server/src/ledgrab/core/devices/device_config.py @@ -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 +) diff --git a/server/src/ledgrab/core/devices/discovery_watcher.py b/server/src/ledgrab/core/devices/discovery_watcher.py index 782242e..24a86f1 100644 --- a/server/src/ledgrab/core/devices/discovery_watcher.py +++ b/server/src/ledgrab/core/devices/discovery_watcher.py @@ -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 diff --git a/server/src/ledgrab/core/devices/dmx_client.py b/server/src/ledgrab/core/devices/dmx_client.py index 6a7f1d1..cb1522d 100644 --- a/server/src/ledgrab/core/devices/dmx_client.py +++ b/server/src/ledgrab/core/devices/dmx_client.py @@ -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: diff --git a/server/src/ledgrab/core/devices/espnow_client.py b/server/src/ledgrab/core/devices/espnow_client.py index d9f69d1..d4611a8 100644 --- a/server/src/ledgrab/core/devices/espnow_client.py +++ b/server/src/ledgrab/core/devices/espnow_client.py @@ -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) diff --git a/server/src/ledgrab/core/devices/gamesense_client.py b/server/src/ledgrab/core/devices/gamesense_client.py index bd6a159..f0099b4 100644 --- a/server/src/ledgrab/core/devices/gamesense_client.py +++ b/server/src/ledgrab/core/devices/gamesense_client.py @@ -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 diff --git a/server/src/ledgrab/core/devices/govee_client.py b/server/src/ledgrab/core/devices/govee_client.py index 95ee14b..b90bfc6 100644 --- a/server/src/ledgrab/core/devices/govee_client.py +++ b/server/src/ledgrab/core/devices/govee_client.py @@ -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": ..., diff --git a/server/src/ledgrab/core/devices/group_client.py b/server/src/ledgrab/core/devices/group_client.py index 181a0c5..3e2fe26 100644 --- a/server/src/ledgrab/core/devices/group_client.py +++ b/server/src/ledgrab/core/devices/group_client.py @@ -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 diff --git a/server/src/ledgrab/core/devices/hue_client.py b/server/src/ledgrab/core/devices/hue_client.py index f53accd..b126c41 100644 --- a/server/src/ledgrab/core/devices/hue_client.py +++ b/server/src/ledgrab/core/devices/hue_client.py @@ -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("/") diff --git a/server/src/ledgrab/core/devices/led_client.py b/server/src/ledgrab/core/devices/led_client.py index 4e28d8c..20350ca 100644 --- a/server/src/ledgrab/core/devices/led_client.py +++ b/server/src/ledgrab/core/devices/led_client.py @@ -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) diff --git a/server/src/ledgrab/core/devices/lifx_client.py b/server/src/ledgrab/core/devices/lifx_client.py index 0a34948..46f5d2b 100644 --- a/server/src/ledgrab/core/devices/lifx_client.py +++ b/server/src/ledgrab/core/devices/lifx_client.py @@ -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(" 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) diff --git a/server/src/ledgrab/core/devices/mock_client.py b/server/src/ledgrab/core/devices/mock_client.py index c34996e..86b53e0 100644 --- a/server/src/ledgrab/core/devices/mock_client.py +++ b/server/src/ledgrab/core/devices/mock_client.py @@ -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, diff --git a/server/src/ledgrab/core/devices/mqtt_client.py b/server/src/ledgrab/core/devices/mqtt_client.py index 119939d..a0a3069 100644 --- a/server/src/ledgrab/core/devices/mqtt_client.py +++ b/server/src/ledgrab/core/devices/mqtt_client.py @@ -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 diff --git a/server/src/ledgrab/core/devices/nanoleaf_client.py b/server/src/ledgrab/core/devices/nanoleaf_client.py index c7fe395..233315d 100644 --- a/server/src/ledgrab/core/devices/nanoleaf_client.py +++ b/server/src/ledgrab/core/devices/nanoleaf_client.py @@ -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//info``. Without a token we can't authenticate, so we fall back to GET ``/api/v1`` which returns 401 when the host is diff --git a/server/src/ledgrab/core/devices/opc_client.py b/server/src/ledgrab/core/devices/opc_client.py index bf75242..7fec549 100644 --- a/server/src/ledgrab/core/devices/opc_client.py +++ b/server/src/ledgrab/core/devices/opc_client.py @@ -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.""" diff --git a/server/src/ledgrab/core/devices/openrgb_client.py b/server/src/ledgrab/core/devices/openrgb_client.py index 4232a25..2c1f53d 100644 --- a/server/src/ledgrab/core/devices/openrgb_client.py +++ b/server/src/ledgrab/core/devices/openrgb_client.py @@ -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. diff --git a/server/src/ledgrab/core/devices/pixel_reduce.py b/server/src/ledgrab/core/devices/pixel_reduce.py index 9d3ccdd..8f22d56 100644 --- a/server/src/ledgrab/core/devices/pixel_reduce.py +++ b/server/src/ledgrab/core/devices/pixel_reduce.py @@ -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. diff --git a/server/src/ledgrab/core/devices/serial_transport.py b/server/src/ledgrab/core/devices/serial_transport.py index e247225..ccfdced 100644 --- a/server/src/ledgrab/core/devices/serial_transport.py +++ b/server/src/ledgrab/core/devices/serial_transport.py @@ -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()``.""" diff --git a/server/src/ledgrab/core/devices/spi_client.py b/server/src/ledgrab/core/devices/spi_client.py index 776f80d..83e9825 100644 --- a/server/src/ledgrab/core/devices/spi_client.py +++ b/server/src/ledgrab/core/devices/spi_client.py @@ -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 diff --git a/server/src/ledgrab/core/devices/usbhid_client.py b/server/src/ledgrab/core/devices/usbhid_client.py index 07ff008..3488338 100644 --- a/server/src/ledgrab/core/devices/usbhid_client.py +++ b/server/src/ledgrab/core/devices/usbhid_client.py @@ -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: diff --git a/server/src/ledgrab/core/devices/wiz_client.py b/server/src/ledgrab/core/devices/wiz_client.py index 1cde22c..2076084 100644 --- a/server/src/ledgrab/core/devices/wiz_client.py +++ b/server/src/ledgrab/core/devices/wiz_client.py @@ -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) diff --git a/server/src/ledgrab/core/devices/wled_client.py b/server/src/ledgrab/core/devices/wled_client.py index cf4aa8b..e96e5d6 100644 --- a/server/src/ledgrab/core/devices/wled_client.py +++ b/server/src/ledgrab/core/devices/wled_client.py @@ -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("/") diff --git a/server/src/ledgrab/core/devices/wled_provider.py b/server/src/ledgrab/core/devices/wled_provider.py index 144add3..cc7a35f 100644 --- a/server/src/ledgrab/core/devices/wled_provider.py +++ b/server/src/ledgrab/core/devices/wled_provider.py @@ -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. diff --git a/server/src/ledgrab/core/devices/ws_client.py b/server/src/ledgrab/core/devices/ws_client.py index c32444d..5a35d35 100644 --- a/server/src/ledgrab/core/devices/ws_client.py +++ b/server/src/ledgrab/core/devices/ws_client.py @@ -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, diff --git a/server/src/ledgrab/core/devices/yeelight_client.py b/server/src/ledgrab/core/devices/yeelight_client.py index 910fbd3..d3b9ae5 100644 --- a/server/src/ledgrab/core/devices/yeelight_client.py +++ b/server/src/ledgrab/core/devices/yeelight_client.py @@ -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 diff --git a/server/src/ledgrab/core/filters/auto_crop.py b/server/src/ledgrab/core/filters/auto_crop.py index 07c3a63..e35c659 100644 --- a/server/src/ledgrab/core/filters/auto_crop.py +++ b/server/src/ledgrab/core/filters/auto_crop.py @@ -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)) diff --git a/server/src/ledgrab/core/filters/base.py b/server/src/ledgrab/core/filters/base.py index eda9a44..4124717 100644 --- a/server/src/ledgrab/core/filters/base.py +++ b/server/src/ledgrab/core/filters/base.py @@ -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 diff --git a/server/src/ledgrab/core/filters/brightness.py b/server/src/ledgrab/core/filters/brightness.py index 2b79a34..d2a0c8c 100644 --- a/server/src/ledgrab/core/filters/brightness.py +++ b/server/src/ledgrab/core/filters/brightness.py @@ -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] diff --git a/server/src/ledgrab/core/filters/color_correction.py b/server/src/ledgrab/core/filters/color_correction.py index d5402cb..069f5c5 100644 --- a/server/src/ledgrab/core/filters/color_correction.py +++ b/server/src/ledgrab/core/filters/color_correction.py @@ -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] diff --git a/server/src/ledgrab/core/filters/contrast.py b/server/src/ledgrab/core/filters/contrast.py index 033a03e..21ede9d 100644 --- a/server/src/ledgrab/core/filters/contrast.py +++ b/server/src/ledgrab/core/filters/contrast.py @@ -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] diff --git a/server/src/ledgrab/core/filters/css_filter_template.py b/server/src/ledgrab/core/filters/css_filter_template.py index 294122b..a5bad8f 100644 --- a/server/src/ledgrab/core/filters/css_filter_template.py +++ b/server/src/ledgrab/core/filters/css_filter_template.py @@ -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 diff --git a/server/src/ledgrab/core/filters/downscaler.py b/server/src/ledgrab/core/filters/downscaler.py index 9464075..a92f9f4 100644 --- a/server/src/ledgrab/core/filters/downscaler.py +++ b/server/src/ledgrab/core/filters/downscaler.py @@ -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 diff --git a/server/src/ledgrab/core/filters/filter_template.py b/server/src/ledgrab/core/filters/filter_template.py index 8fceefb..d8499ba 100644 --- a/server/src/ledgrab/core/filters/filter_template.py +++ b/server/src/ledgrab/core/filters/filter_template.py @@ -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 diff --git a/server/src/ledgrab/core/filters/flip.py b/server/src/ledgrab/core/filters/flip.py index 963f619..a9f4ca3 100644 --- a/server/src/ledgrab/core/filters/flip.py +++ b/server/src/ledgrab/core/filters/flip.py @@ -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: diff --git a/server/src/ledgrab/core/filters/frame_interpolation.py b/server/src/ledgrab/core/filters/frame_interpolation.py index 1dac921..cd0d96f 100644 --- a/server/src/ledgrab/core/filters/frame_interpolation.py +++ b/server/src/ledgrab/core/filters/frame_interpolation.py @@ -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() diff --git a/server/src/ledgrab/core/filters/gamma.py b/server/src/ledgrab/core/filters/gamma.py index aff30c1..c8ed512 100644 --- a/server/src/ledgrab/core/filters/gamma.py +++ b/server/src/ledgrab/core/filters/gamma.py @@ -1,6 +1,6 @@ """Gamma correction postprocessing filter.""" -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List import numpy as np @@ -37,7 +37,7 @@ class GammaFilter(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] diff --git a/server/src/ledgrab/core/filters/hsl_shift.py b/server/src/ledgrab/core/filters/hsl_shift.py index e0be3f0..e4155ca 100644 --- a/server/src/ledgrab/core/filters/hsl_shift.py +++ b/server/src/ledgrab/core/filters/hsl_shift.py @@ -1,6 +1,6 @@ """HSL shift postprocessing filter — hue rotation and lightness adjustment.""" -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List import numpy as np @@ -22,7 +22,7 @@ class HslShiftFilter(PostprocessingFilter): def __init__(self, options: Dict[str, Any]): super().__init__(options) - self._f32_buf: Optional[np.ndarray] = None + self._f32_buf: np.ndarray | None = None @classmethod def get_options_schema(cls) -> List[FilterOptionDef]: @@ -47,7 +47,7 @@ class HslShiftFilter(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: hue_shift = self.options["hue"] lightness = self.options["lightness"] if hue_shift == 0 and lightness == 1.0: diff --git a/server/src/ledgrab/core/filters/noise_gate.py b/server/src/ledgrab/core/filters/noise_gate.py index 1568559..d50495e 100644 --- a/server/src/ledgrab/core/filters/noise_gate.py +++ b/server/src/ledgrab/core/filters/noise_gate.py @@ -1,6 +1,6 @@ """Noise gate postprocessing filter.""" -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List import numpy as np @@ -23,11 +23,11 @@ class NoiseGateFilter(PostprocessingFilter): def __init__(self, options: Dict[str, Any]): super().__init__(options) - self._prev_frame: Optional[np.ndarray] = None + self._prev_frame: np.ndarray | None = None # Pre-allocated scratch buffers (avoid per-frame allocation) - self._i16_cur: Optional[np.ndarray] = None - self._i16_prev: Optional[np.ndarray] = None - self._buf_shape: Optional[tuple] = None + self._i16_cur: np.ndarray | None = None + self._i16_prev: np.ndarray | None = None + self._buf_shape: tuple | None = None @classmethod def get_options_schema(cls) -> List[FilterOptionDef]: @@ -43,7 +43,7 @@ class NoiseGateFilter(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["threshold"] h, w, c = image.shape shape = (h, w, c) diff --git a/server/src/ledgrab/core/filters/palette_quantization.py b/server/src/ledgrab/core/filters/palette_quantization.py index f394bac..a2278be 100644 --- a/server/src/ledgrab/core/filters/palette_quantization.py +++ b/server/src/ledgrab/core/filters/palette_quantization.py @@ -1,7 +1,7 @@ """Palette quantization postprocessing filter.""" import re -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List import numpy as np @@ -23,7 +23,7 @@ _PRESETS = { _HEX_RE = re.compile(r"^#?([0-9a-fA-F]{6})$") -def _parse_hex_colors(color_str: str) -> Optional[np.ndarray]: +def _parse_hex_colors(color_str: str) -> np.ndarray | None: """Parse comma-separated hex colors into (N, 3) uint8 array.""" colors = [] for part in color_str.split(","): @@ -46,7 +46,7 @@ class PaletteQuantizationFilter(PostprocessingFilter): def __init__(self, options: Dict[str, Any]): super().__init__(options) - self._palette: Optional[np.ndarray] = None + self._palette: np.ndarray | None = None self._rebuild_palette() def _rebuild_palette(self): @@ -99,7 +99,7 @@ class PaletteQuantizationFilter(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._palette is None or len(self._palette) == 0: return None diff --git a/server/src/ledgrab/core/filters/pixelate.py b/server/src/ledgrab/core/filters/pixelate.py index 6b1cad7..477d1e8 100644 --- a/server/src/ledgrab/core/filters/pixelate.py +++ b/server/src/ledgrab/core/filters/pixelate.py @@ -1,6 +1,6 @@ """Pixelate postprocessing filter.""" -from typing import List, Optional +from typing import List import numpy as np @@ -31,7 +31,7 @@ class PixelateFilter(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: block_size = self.options["block_size"] if block_size <= 1: return None diff --git a/server/src/ledgrab/core/filters/reverse.py b/server/src/ledgrab/core/filters/reverse.py index a02988f..80bf095 100644 --- a/server/src/ledgrab/core/filters/reverse.py +++ b/server/src/ledgrab/core/filters/reverse.py @@ -1,6 +1,6 @@ """Reverse filter — reverses the LED order in a 1D strip.""" -from typing import List, Optional +from typing import List import numpy as np @@ -21,10 +21,10 @@ class ReverseFilter(PostprocessingFilter): 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: """Reverse image horizontally (for 2D fallback).""" return image[:, ::-1].copy() - def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]: + def process_strip(self, strip: np.ndarray) -> np.ndarray | None: """Reverse the LED array order.""" return strip[::-1].copy() diff --git a/server/src/ledgrab/core/filters/saturation.py b/server/src/ledgrab/core/filters/saturation.py index a7ccb20..718bc15 100644 --- a/server/src/ledgrab/core/filters/saturation.py +++ b/server/src/ledgrab/core/filters/saturation.py @@ -1,6 +1,6 @@ """Saturation postprocessing filter.""" -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List import numpy as np @@ -18,8 +18,8 @@ class SaturationFilter(PostprocessingFilter): def __init__(self, options: Dict[str, Any]): super().__init__(options) - self._i32_buf: Optional[np.ndarray] = None - self._i32_gray: Optional[np.ndarray] = None + self._i32_buf: np.ndarray | None = None + self._i32_gray: np.ndarray | None = None @classmethod def get_options_schema(cls) -> List[FilterOptionDef]: @@ -35,7 +35,7 @@ class SaturationFilter(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: value = self.options["value"] if value == 1.0: return None diff --git a/server/src/ledgrab/core/filters/temporal_blur.py b/server/src/ledgrab/core/filters/temporal_blur.py index 4ec49c1..58118e3 100644 --- a/server/src/ledgrab/core/filters/temporal_blur.py +++ b/server/src/ledgrab/core/filters/temporal_blur.py @@ -1,6 +1,6 @@ """Temporal blur postprocessing filter — blends current frame with history.""" -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List import numpy as np @@ -22,7 +22,7 @@ class TemporalBlurFilter(PostprocessingFilter): def __init__(self, options: Dict[str, Any]): super().__init__(options) - self._acc: Optional[np.ndarray] = None + self._acc: np.ndarray | None = None @classmethod def get_options_schema(cls) -> List[FilterOptionDef]: @@ -38,7 +38,7 @@ class TemporalBlurFilter(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: strength = self.options["strength"] if strength == 0.0: self._acc = None @@ -58,7 +58,7 @@ class TemporalBlurFilter(PostprocessingFilter): np.copyto(image, self._acc, casting="unsafe") return None - def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]: + def process_strip(self, strip: np.ndarray) -> np.ndarray | None: """Optimized strip path — avoids reshape overhead.""" strength = self.options["strength"] if strength == 0.0: diff --git a/server/src/ledgrab/core/home_assistant/ha_manager.py b/server/src/ledgrab/core/home_assistant/ha_manager.py index d05039c..5861767 100644 --- a/server/src/ledgrab/core/home_assistant/ha_manager.py +++ b/server/src/ledgrab/core/home_assistant/ha_manager.py @@ -5,7 +5,7 @@ conditions) sharing the same WebSocket connection per HA instance. """ import asyncio -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List from ledgrab.core.home_assistant.ha_runtime import HAEntityState, HARuntime from ledgrab.storage.home_assistant_store import HomeAssistantStore @@ -53,7 +53,7 @@ class HomeAssistantManager: else: self._runtimes[source_id] = (runtime, count - 1) - def get_state(self, source_id: str, entity_id: str) -> Optional[HAEntityState]: + def get_state(self, source_id: str, entity_id: str) -> HAEntityState | None: """Get cached entity state from a running runtime (synchronous).""" entry = self._runtimes.get(source_id) if entry is None: @@ -61,7 +61,7 @@ class HomeAssistantManager: runtime, _count = entry return runtime.get_state(entity_id) - def get_runtime(self, source_id: str) -> Optional[HARuntime]: + def get_runtime(self, source_id: str) -> HARuntime | None: """Get a running runtime without changing ref count (for read-only access).""" entry = self._runtimes.get(source_id) if entry is None: diff --git a/server/src/ledgrab/core/home_assistant/ha_runtime.py b/server/src/ledgrab/core/home_assistant/ha_runtime.py index b2381ed..090d94f 100644 --- a/server/src/ledgrab/core/home_assistant/ha_runtime.py +++ b/server/src/ledgrab/core/home_assistant/ha_runtime.py @@ -9,7 +9,7 @@ import json import threading import time from dataclasses import dataclass, field -from typing import Any, Callable, Dict, List, Optional, Set +from typing import Any, Callable, Dict, List, Set from ledgrab.storage.home_assistant_source import HomeAssistantSource from ledgrab.utils import get_logger @@ -53,7 +53,7 @@ class HARuntime: self._callbacks: Dict[str, Set[Callable]] = {} # Async task management - self._task: Optional[asyncio.Task] = None + self._task: asyncio.Task | None = None self._ws: Any = None # live websocket connection (set during _connection_loop) self._connected = False self._msg_id = 0 @@ -66,7 +66,7 @@ class HARuntime: def source_id(self) -> str: return self._source_id - def get_state(self, entity_id: str) -> Optional[HAEntityState]: + def get_state(self, entity_id: str) -> HAEntityState | None: """Get cached entity state (synchronous, thread-safe).""" with self._lock: return self._states.get(entity_id) diff --git a/server/src/ledgrab/core/mqtt/legacy_migration.py b/server/src/ledgrab/core/mqtt/legacy_migration.py index a506644..c0ce56c 100644 --- a/server/src/ledgrab/core/mqtt/legacy_migration.py +++ b/server/src/ledgrab/core/mqtt/legacy_migration.py @@ -17,7 +17,6 @@ from __future__ import annotations import os from pathlib import Path -from typing import Optional import yaml @@ -27,7 +26,7 @@ from ledgrab.utils import get_logger logger = get_logger(__name__) -def _legacy_mqtt_from_env() -> Optional[dict]: +def _legacy_mqtt_from_env() -> dict | None: """Read legacy ``LEDGRAB_MQTT__*`` env vars (pydantic-settings convention).""" enabled = os.environ.get("LEDGRAB_MQTT__ENABLED", "").strip().lower() host = os.environ.get("LEDGRAB_MQTT__BROKER_HOST", "").strip() @@ -56,7 +55,7 @@ def _candidate_config_paths() -> list[Path]: return paths -def _legacy_mqtt_from_yaml() -> Optional[dict]: +def _legacy_mqtt_from_yaml() -> dict | None: """Read legacy ``mqtt:`` block from the platform config.yaml.""" for path in _candidate_config_paths(): if not path.is_file(): diff --git a/server/src/ledgrab/core/mqtt/mqtt_manager.py b/server/src/ledgrab/core/mqtt/mqtt_manager.py index e9dadd5..1c6ba3e 100644 --- a/server/src/ledgrab/core/mqtt/mqtt_manager.py +++ b/server/src/ledgrab/core/mqtt/mqtt_manager.py @@ -6,7 +6,7 @@ MQTTSource instance. """ import asyncio -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List from ledgrab.core.mqtt.mqtt_runtime import MQTTRuntime from ledgrab.storage.mqtt_source_store import MQTTSourceStore @@ -54,7 +54,7 @@ class MQTTManager: else: self._runtimes[source_id] = (runtime, count - 1) - def get_last_value(self, source_id: str, topic: str) -> Optional[str]: + def get_last_value(self, source_id: str, topic: str) -> str | None: """Get cached topic value from a running runtime (synchronous).""" entry = self._runtimes.get(source_id) if entry is None: @@ -62,7 +62,7 @@ class MQTTManager: runtime, _count = entry return runtime.get_last_value(topic) - def get_runtime(self, source_id: str) -> Optional[MQTTRuntime]: + def get_runtime(self, source_id: str) -> MQTTRuntime | None: """Get a running runtime without changing ref count (for read-only access).""" entry = self._runtimes.get(source_id) if entry is None: @@ -148,7 +148,7 @@ class MQTTManager: ) return result - def get_first_runtime(self) -> Optional[MQTTRuntime]: + def get_first_runtime(self) -> MQTTRuntime | None: """Get the first available connected runtime (fallback for unlinked consumers).""" for _source_id, (runtime, _count) in self._runtimes.items(): if runtime.is_connected: diff --git a/server/src/ledgrab/core/mqtt/mqtt_runtime.py b/server/src/ledgrab/core/mqtt/mqtt_runtime.py index a9f4601..ac4ef19 100644 --- a/server/src/ledgrab/core/mqtt/mqtt_runtime.py +++ b/server/src/ledgrab/core/mqtt/mqtt_runtime.py @@ -6,7 +6,7 @@ Refactored from the former singleton MQTTService. import asyncio import json -from typing import Callable, Dict, Optional, Set +from typing import Callable, Dict, Set import aiomqtt @@ -38,8 +38,8 @@ class MQTTRuntime: self._client_id = source.client_id self._base_topic = source.base_topic - self._client: Optional[aiomqtt.Client] = None - self._task: Optional[asyncio.Task] = None + self._client: aiomqtt.Client | None = None + self._task: asyncio.Task | None = None self._connected = False # Subscription management @@ -160,7 +160,7 @@ class MQTTRuntime: except Exception as e: logger.warning("MQTT subscribe failed (%s/%s): %s", self._source_id, topic, e) - def get_last_value(self, topic: str) -> Optional[str]: + def get_last_value(self, topic: str) -> str | None: """Get cached last value for a topic (synchronous — for automation evaluation).""" return self._topic_cache.get(topic) diff --git a/server/src/ledgrab/core/processing/api_input_stream.py b/server/src/ledgrab/core/processing/api_input_stream.py index 56014ec..da4c22c 100644 --- a/server/src/ledgrab/core/processing/api_input_stream.py +++ b/server/src/ledgrab/core/processing/api_input_stream.py @@ -11,7 +11,6 @@ the target processor thread. import threading import time -from typing import Optional import numpy as np @@ -39,7 +38,7 @@ class ApiInputColorStripStream(ColorStripStream): """ self._lock = threading.Lock() self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._fps = 30 # Parse config @@ -276,7 +275,7 @@ class ApiInputColorStripStream(ColorStripStream): self._thread = None logger.info("ApiInputColorStripStream stopped") - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._lock: return self._colors diff --git a/server/src/ledgrab/core/processing/audio_stream.py b/server/src/ledgrab/core/processing/audio_stream.py index ad25af3..909c0e3 100644 --- a/server/src/ledgrab/core/processing/audio_stream.py +++ b/server/src/ledgrab/core/processing/audio_stream.py @@ -16,7 +16,6 @@ Seven visualization modes: import math import threading import time -from typing import Optional import numpy as np @@ -61,7 +60,7 @@ class AudioColorStripStream(ColorStripStream): self._colors_lock = threading.Lock() self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._fps = 30 self._frame_time = 1.0 / 30 @@ -72,11 +71,11 @@ class AudioColorStripStream(ColorStripStream): self._pulse_brightness = 0.0 # Smoothed spectrum for temporal smoothing between frames - self._prev_spectrum: Optional[np.ndarray] = None + self._prev_spectrum: np.ndarray | None = None self._prev_rms = 0.0 # Music analyzer (for semantic modes: pulse_on_beat, energy_gradient, etc.) - self._music_analyzer: Optional[MusicAnalyzer] = None + self._music_analyzer: MusicAnalyzer | None = None self._music_features = None # latest MusicFeatures snapshot # Strobe state @@ -168,7 +167,7 @@ class AudioColorStripStream(ColorStripStream): self._rebuild_filter_pipeline() with self._colors_lock: - self._colors: Optional[np.ndarray] = None + self._colors: np.ndarray | None = None def _rebuild_filter_pipeline(self) -> None: """Build (or rebuild) the audio filter pipeline from processing template IDs.""" @@ -249,7 +248,7 @@ class AudioColorStripStream(ColorStripStream): self._prev_spectrum = None logger.info("AudioColorStripStream stopped") - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._colors_lock: return self._colors diff --git a/server/src/ledgrab/core/processing/auto_restart.py b/server/src/ledgrab/core/processing/auto_restart.py index 3ab5a83..2f55a36 100644 --- a/server/src/ledgrab/core/processing/auto_restart.py +++ b/server/src/ledgrab/core/processing/auto_restart.py @@ -9,7 +9,6 @@ Extracted from processor_manager.py to keep files under 800 lines. import asyncio import time from dataclasses import dataclass -from typing import Optional from ledgrab.utils import get_logger @@ -29,7 +28,7 @@ class RestartState: attempts: int = 0 first_crash_time: float = 0.0 last_crash_time: float = 0.0 - restart_task: Optional[asyncio.Task] = None + restart_task: asyncio.Task | None = None enabled: bool = True # disabled on manual stop diff --git a/server/src/ledgrab/core/processing/candlelight_stream.py b/server/src/ledgrab/core/processing/candlelight_stream.py index e13494b..231fc7e 100644 --- a/server/src/ledgrab/core/processing/candlelight_stream.py +++ b/server/src/ledgrab/core/processing/candlelight_stream.py @@ -14,7 +14,7 @@ Features: import math import threading import time -from typing import List, Optional +from typing import List import numpy as np @@ -67,17 +67,17 @@ class CandlelightColorStripStream(ColorStripStream): def __init__(self, source): self._colors_lock = threading.Lock() self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._fps = 30 self._frame_time = 1.0 / 30 self._clock = None self._led_count = 1 self._auto_size = True # Scratch arrays - self._s_bright: Optional[np.ndarray] = None - self._s_noise: Optional[np.ndarray] = None - self._s_x: Optional[np.ndarray] = None - self._s_drip: Optional[np.ndarray] = None + self._s_bright: np.ndarray | None = None + self._s_noise: np.ndarray | None = None + self._s_x: np.ndarray | None = None + self._s_drip: np.ndarray | None = None self._pool_n = 0 # Wax drip events: [pos, brightness, phase(0=dim,1=recover)] self._drip_events: List[List[float]] = [] @@ -101,7 +101,7 @@ class CandlelightColorStripStream(ColorStripStream): self._auto_size = not _lc self._led_count = _lc if _lc and _lc > 0 else 1 with self._colors_lock: - self._colors: Optional[np.ndarray] = None + self._colors: np.ndarray | None = None def configure(self, device_led_count: int) -> None: if self._auto_size and device_led_count > 0: @@ -144,7 +144,7 @@ class CandlelightColorStripStream(ColorStripStream): self._thread = None logger.info("CandlelightColorStripStream stopped") - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._colors_lock: return self._colors diff --git a/server/src/ledgrab/core/processing/color_strip/base.py b/server/src/ledgrab/core/processing/color_strip/base.py index c7b1b47..17953f5 100644 --- a/server/src/ledgrab/core/processing/color_strip/base.py +++ b/server/src/ledgrab/core/processing/color_strip/base.py @@ -1,7 +1,6 @@ """Base ColorStripStream ABC and shared noise utility.""" from abc import ABC, abstractmethod -from typing import Optional import numpy as np @@ -46,7 +45,7 @@ class ColorStripStream(ABC): """Target processing rate.""" @property - def actual_fps(self) -> Optional[float]: + def actual_fps(self) -> float | None: """Measured rate of *new* frames the stream is delivering, or ``None``. Only streams backed by an external capture (screen, audio device, API @@ -74,12 +73,12 @@ class ColorStripStream(ABC): return True @property - def display_index(self) -> Optional[int]: + def display_index(self) -> int | None: """Display index of the underlying capture, or None.""" return None @property - def calibration(self) -> Optional[CalibrationConfig]: + def calibration(self) -> CalibrationConfig | None: """Calibration config, or None if not applicable.""" return None @@ -92,7 +91,7 @@ class ColorStripStream(ABC): """Stop producing colors and release resources.""" @abstractmethod - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: """Get the most recent LED color array (led_count, 3) uint8, or None.""" def get_last_timing(self) -> dict: diff --git a/server/src/ledgrab/core/processing/color_strip/gradient.py b/server/src/ledgrab/core/processing/color_strip/gradient.py index f287fc1..2e6b7fc 100644 --- a/server/src/ledgrab/core/processing/color_strip/gradient.py +++ b/server/src/ledgrab/core/processing/color_strip/gradient.py @@ -3,7 +3,6 @@ import math import threading import time -from typing import Optional import numpy as np @@ -32,7 +31,7 @@ class GradientColorStripStream(ColorStripStream): def __init__(self, source): self._colors_lock = threading.Lock() self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._fps = 30 self._frame_time = 1.0 / 30 self._clock = None # optional SyncClockRuntime @@ -129,7 +128,7 @@ class GradientColorStripStream(ColorStripStream): self._thread = None logger.info("GradientColorStripStream stopped") - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._colors_lock: return self._colors @@ -155,9 +154,9 @@ class GradientColorStripStream(ColorStripStream): Uses double-buffered output arrays plus a uint16 scratch buffer for integer-math brightness scaling, avoiding per-frame numpy allocations. """ - _cached_base: Optional[np.ndarray] = None + _cached_base: np.ndarray | None = None _cached_n: int = 0 - _cached_stops: Optional[list] = None + _cached_stops: list | None = None _cached_easing: str = "" # Double-buffer pool + uint16 scratch for brightness math _pool_n = 0 diff --git a/server/src/ledgrab/core/processing/color_strip/picture.py b/server/src/ledgrab/core/processing/color_strip/picture.py index c914781..47d44b0 100644 --- a/server/src/ledgrab/core/processing/color_strip/picture.py +++ b/server/src/ledgrab/core/processing/color_strip/picture.py @@ -3,7 +3,6 @@ import threading import time from collections import deque -from typing import Optional import numpy as np @@ -72,12 +71,12 @@ class PictureColorStripStream(ColorStripStream): self._led_count: int = source.led_count if source.led_count > 0 else cal_leds # Thread-safe color cache - self._latest_colors: Optional[np.ndarray] = None + self._latest_colors: np.ndarray | None = None self._colors_lock = threading.Lock() - self._previous_colors: Optional[np.ndarray] = None + self._previous_colors: np.ndarray | None = None self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._last_timing: dict = {} # Rolling 1s window of timestamps for *new* frames received from @@ -99,7 +98,7 @@ class PictureColorStripStream(ColorStripStream): return self._fps @property - def actual_fps(self) -> Optional[float]: + def actual_fps(self) -> float | None: """Measured new-frame rate over the last 1 second. Returns the count of distinct frames the picture loop accepted in @@ -128,13 +127,13 @@ class PictureColorStripStream(ColorStripStream): return self._led_count @property - def display_index(self) -> Optional[int]: + def display_index(self) -> int | None: if self._live_streams: return None # multi-source, ambiguous return self._live_stream.display_index @property - def calibration(self) -> Optional[CalibrationConfig]: + def calibration(self) -> CalibrationConfig | None: return self._calibration def start(self) -> None: @@ -161,7 +160,7 @@ class PictureColorStripStream(ColorStripStream): self._new_frame_timestamps.clear() logger.info("PictureColorStripStream stopped") - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._colors_lock: return self._latest_colors diff --git a/server/src/ledgrab/core/processing/color_strip/single.py b/server/src/ledgrab/core/processing/color_strip/single.py index eecc26c..638dd60 100644 --- a/server/src/ledgrab/core/processing/color_strip/single.py +++ b/server/src/ledgrab/core/processing/color_strip/single.py @@ -4,7 +4,6 @@ import colorsys import math import threading import time -from typing import Optional import numpy as np @@ -32,7 +31,7 @@ class SingleColorStripStream(ColorStripStream): """ self._colors_lock = threading.Lock() self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._fps = 30 self._frame_time = 1.0 / 30 self._clock = None # optional SyncClockRuntime @@ -113,7 +112,7 @@ class SingleColorStripStream(ColorStripStream): self._thread = None logger.info("SingleColorStripStream stopped") - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._colors_lock: return self._colors diff --git a/server/src/ledgrab/core/processing/color_strip_stream_manager.py b/server/src/ledgrab/core/processing/color_strip_stream_manager.py index 9ff4db7..7670bb7 100644 --- a/server/src/ledgrab/core/processing/color_strip_stream_manager.py +++ b/server/src/ledgrab/core/processing/color_strip_stream_manager.py @@ -9,7 +9,7 @@ without interfering with other targets. """ from dataclasses import dataclass -from typing import Dict, Optional +from typing import Dict from ledgrab.core.processing.color_strip_kinds import ( StreamDeps, @@ -35,7 +35,7 @@ class _ColorStripEntry: # Per-consumer target FPS values (target_id → fps) target_fps: Dict[str, int] = None # Clock ID currently acquired for this stream (for correct release) - clock_id: Optional[str] = None + clock_id: str | None = None # Value stream IDs acquired for BindableFloat properties (prop → vs_id) bound_vs_ids: Dict[str, str] = None @@ -100,7 +100,7 @@ class ColorStripStreamManager: self._audio_processing_template_store = audio_processing_template_store self._streams: Dict[str, _ColorStripEntry] = {} - def _inject_clock(self, css_stream, source) -> Optional[str]: + def _inject_clock(self, css_stream, source) -> str | None: """Inject a SyncClockRuntime into the stream if source has clock_id. Returns the clock_id that was acquired, or None. diff --git a/server/src/ledgrab/core/processing/composite_stream.py b/server/src/ledgrab/core/processing/composite_stream.py index fb7ab11..96e421f 100644 --- a/server/src/ledgrab/core/processing/composite_stream.py +++ b/server/src/ledgrab/core/processing/composite_stream.py @@ -2,7 +2,7 @@ import threading import time -from typing import Dict, List, Optional +from typing import Dict, List import numpy as np @@ -61,9 +61,9 @@ class CompositeColorStripStream(ColorStripStream): self._frame_time: float = 1.0 / 30 self._running = False - self._thread: Optional[threading.Thread] = None - self._latest_colors: Optional[np.ndarray] = None - self._latest_layer_colors: Optional[List[np.ndarray]] = None + self._thread: threading.Thread | None = None + self._latest_colors: np.ndarray | None = None + self._latest_layer_colors: List[np.ndarray] | None = None self._colors_lock = threading.Lock() self._need_layer_snapshots: bool = False # set True when get_layer_colors() is called @@ -84,12 +84,12 @@ class CompositeColorStripStream(ColorStripStream): # Pre-allocated scratch (rebuilt when LED count changes) self._pool_n = 0 - self._result_a: Optional[np.ndarray] = None - self._result_b: Optional[np.ndarray] = None + self._result_a: np.ndarray | None = None + self._result_b: np.ndarray | None = None self._use_a = True - self._u16_a: Optional[np.ndarray] = None - self._u16_b: Optional[np.ndarray] = None - self._resize_buf: Optional[np.ndarray] = None + self._u16_a: np.ndarray | None = None + self._u16_b: np.ndarray | None = None + self._resize_buf: np.ndarray | None = None # ── ColorStripStream interface ────────────────────────────── @@ -98,7 +98,7 @@ class CompositeColorStripStream(ColorStripStream): return self._fps @property - def actual_fps(self) -> Optional[float]: + def actual_fps(self) -> float | None: """Aggregate measured capture rate across capture-backed sub-streams. Sums `actual_fps` from each sub-stream that reports one (i.e. @@ -157,20 +157,20 @@ class CompositeColorStripStream(ColorStripStream): self._release_sub_streams() logger.info(f"CompositeColorStripStream stopped: {self._source_id}") - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._colors_lock: return self._latest_colors - def get_layer_colors(self) -> Optional[List[np.ndarray]]: + def get_layer_colors(self) -> List[np.ndarray] | None: """Return per-layer color snapshots (after resize/brightness, before blending).""" self._need_layer_snapshots = True with self._colors_lock: return self._latest_layer_colors - def get_layer_brightness(self) -> List[Optional[float]]: + def get_layer_brightness(self) -> List[float | None]: """Return per-layer brightness values (0.0-1.0) from value sources, or None if no source.""" enabled = [layer for layer in self._layers if layer.get("enabled", True)] - result: List[Optional[float]] = [] + result: List[float | None] = [] with self._sub_lock: for i in range(len(enabled)): if i in self._brightness_streams: diff --git a/server/src/ledgrab/core/processing/daylight_settings.py b/server/src/ledgrab/core/processing/daylight_settings.py index a975426..350a2ab 100644 --- a/server/src/ledgrab/core/processing/daylight_settings.py +++ b/server/src/ledgrab/core/processing/daylight_settings.py @@ -11,7 +11,6 @@ from __future__ import annotations import threading import time -from typing import Optional from ledgrab.utils import get_logger @@ -60,7 +59,7 @@ def get_daylight_timezone() -> str: return fresh -def set_daylight_timezone(tz: Optional[str]) -> str: +def set_daylight_timezone(tz: str | None) -> str: """Persist the global daylight timezone and refresh the cache. Returns the canonicalised stored value (empty string for None / blank). diff --git a/server/src/ledgrab/core/processing/daylight_stream.py b/server/src/ledgrab/core/processing/daylight_stream.py index d96b378..5de51f1 100644 --- a/server/src/ledgrab/core/processing/daylight_stream.py +++ b/server/src/ledgrab/core/processing/daylight_stream.py @@ -13,7 +13,6 @@ import datetime import math import threading import time -from typing import Optional import numpy as np @@ -81,7 +80,7 @@ _DEFAULT_SUNRISE = 6.0 _DEFAULT_SUNSET = 19.0 # Global cache of the default static LUT (lazy-built once) -_daylight_lut: Optional[np.ndarray] = None +_daylight_lut: np.ndarray | None = None # ── Solar position helpers ────────────────────────────────────────────── @@ -135,7 +134,7 @@ def _compute_solar_times( return sunrise, sunset -def _utc_offset_hours_for(tz_name: str, when: Optional[datetime.datetime] = None) -> float: +def _utc_offset_hours_for(tz_name: str, when: datetime.datetime | None = None) -> float: """Return the UTC offset (in hours) for the given IANA timezone. Empty/unknown tz falls back to the system local offset for ``when``. @@ -225,7 +224,7 @@ class DaylightColorStripStream(ColorStripStream): def __init__(self, source): self._colors_lock = threading.Lock() self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._fps = 10 self._frame_time = 1.0 / 10 self._clock = None @@ -247,7 +246,7 @@ class DaylightColorStripStream(ColorStripStream): self._led_count = _lc if _lc and _lc > 0 else 1 self._lut_cache = {} with self._colors_lock: - self._colors: Optional[np.ndarray] = None + self._colors: np.ndarray | None = None def _get_lut_for_day(self, day_of_year: int, utc_offset_hours: float = 0.0) -> np.ndarray: """Return a solar-time-aware LUT for the given day (cached).""" @@ -304,7 +303,7 @@ class DaylightColorStripStream(ColorStripStream): self._thread = None logger.info("DaylightColorStripStream stopped") - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._colors_lock: return self._colors diff --git a/server/src/ledgrab/core/processing/device_test_mode.py b/server/src/ledgrab/core/processing/device_test_mode.py index 7d8796c..7a8227f 100644 --- a/server/src/ledgrab/core/processing/device_test_mode.py +++ b/server/src/ledgrab/core/processing/device_test_mode.py @@ -3,7 +3,7 @@ Extracted from processor_manager.py to keep files under 800 lines. """ -from typing import Dict, List, Optional +from typing import Dict, List from ledgrab.core.capture.calibration import CalibrationConfig from ledgrab.core.devices.led_client import ProviderDeps, create_led_client @@ -27,7 +27,7 @@ class DeviceTestModeMixin: self, device_id: str, edges: Dict[str, List[int]], - calibration: Optional[CalibrationConfig] = None, + calibration: CalibrationConfig | None = None, ) -> None: """Set or clear calibration test mode for a device. @@ -170,7 +170,7 @@ class DeviceTestModeMixin: return True return False - def get_display_lock_info(self, display_index: int) -> Optional[str]: + def get_display_lock_info(self, display_index: int) -> str | None: """Get the device ID that is currently capturing from a display.""" for proc in self._processors.values(): if proc.is_running and proc.get_display_index() == display_index: diff --git a/server/src/ledgrab/core/processing/effect_stream.py b/server/src/ledgrab/core/processing/effect_stream.py index cef128e..4b98069 100644 --- a/server/src/ledgrab/core/processing/effect_stream.py +++ b/server/src/ledgrab/core/processing/effect_stream.py @@ -11,7 +11,7 @@ no external dependencies are required. import math import threading import time -from typing import Callable, Dict, Optional +from typing import Callable, Dict import numpy as np @@ -273,36 +273,36 @@ class EffectColorStripStream(ColorStripStream): def __init__(self, source): self._colors_lock = threading.Lock() self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._fps = 30 self._frame_time = 1.0 / 30 self._clock = None # optional SyncClockRuntime self._effective_speed = 1.0 # resolved speed (from clock or source) self._noise = _ValueNoise1D(seed=42) # Fire state — allocated lazily in render loop - self._heat: Optional[np.ndarray] = None + self._heat: np.ndarray | None = None self._heat_n = 0 # Scratch arrays (allocated in _animate_loop when LED count is known) - self._s_f32_a: Optional[np.ndarray] = None - self._s_f32_b: Optional[np.ndarray] = None - self._s_f32_c: Optional[np.ndarray] = None - self._s_i32: Optional[np.ndarray] = None - self._s_f32_rgb: Optional[np.ndarray] = None - self._s_arange: Optional[np.ndarray] = None - self._s_layer1: Optional[np.ndarray] = None - self._s_layer2: Optional[np.ndarray] = None + self._s_f32_a: np.ndarray | None = None + self._s_f32_b: np.ndarray | None = None + self._s_f32_c: np.ndarray | None = None + self._s_i32: np.ndarray | None = None + self._s_f32_rgb: np.ndarray | None = None + self._s_arange: np.ndarray | None = None + self._s_layer1: np.ndarray | None = None + self._s_layer2: np.ndarray | None = None self._plasma_key = (0, 0.0) - self._plasma_x: Optional[np.ndarray] = None + self._plasma_x: np.ndarray | None = None # Bouncing ball state - self._ball_positions: Optional[np.ndarray] = None - self._ball_velocities: Optional[np.ndarray] = None + self._ball_positions: np.ndarray | None = None + self._ball_velocities: np.ndarray | None = None self._ball_last_t = 0.0 # Fireworks state self._fw_particles: list = [] # active particles self._fw_rockets: list = [] # active rockets self._fw_last_launch = 0.0 # Sparkle rain state - self._sparkle_state: Optional[np.ndarray] = None # per-LED brightness 0..1 + self._sparkle_state: np.ndarray | None = None # per-LED brightness 0..1 self._gradient_store = None # injected by stream manager self._update_from_source(source) @@ -345,7 +345,7 @@ class EffectColorStripStream(ColorStripStream): self._scale = bfloat(getattr(source, "scale", 1.0), 1.0) self._mirror = bool(getattr(source, "mirror", False)) with self._colors_lock: - self._colors: Optional[np.ndarray] = None + self._colors: np.ndarray | None = None def configure(self, device_led_count: int) -> None: if self._auto_size and device_led_count > 0: @@ -391,7 +391,7 @@ class EffectColorStripStream(ColorStripStream): self._heat_n = 0 logger.info("EffectColorStripStream stopped") - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._colors_lock: return self._colors diff --git a/server/src/ledgrab/core/processing/game_event_stream.py b/server/src/ledgrab/core/processing/game_event_stream.py index 7f2ad28..63329f4 100644 --- a/server/src/ledgrab/core/processing/game_event_stream.py +++ b/server/src/ledgrab/core/processing/game_event_stream.py @@ -14,7 +14,6 @@ import collections import math import threading import time -from typing import Optional import numpy as np @@ -43,10 +42,10 @@ class GameEventColorStripStream(ColorStripStream): effects override lower ones (same as notification stream). """ - def __init__(self, source, event_bus: Optional[GameEventBus] = None) -> None: + def __init__(self, source, event_bus: GameEventBus | None = None) -> None: self._colors_lock = threading.Lock() self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._fps = 30 self._frame_time = 1.0 / 30 @@ -54,7 +53,7 @@ class GameEventColorStripStream(ColorStripStream): self._event_queue: collections.deque = collections.deque(maxlen=32) # Active effect state - self._active_effect: Optional[dict] = None + self._active_effect: dict | None = None # EventBus reference and subscription IDs self._event_bus = event_bus @@ -86,7 +85,7 @@ class GameEventColorStripStream(ColorStripStream): with self._colors_lock: idle = self.resolve_color("idle_color", self._idle_color) - self._colors: Optional[np.ndarray] = np.zeros( + self._colors: np.ndarray | None = np.zeros( (self._led_count, 3), dtype=np.uint8, ) @@ -154,7 +153,7 @@ class GameEventColorStripStream(ColorStripStream): self._thread = None logger.info("GameEventColorStripStream stopped") - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._colors_lock: return self._colors diff --git a/server/src/ledgrab/core/processing/ha_light_target_processor.py b/server/src/ledgrab/core/processing/ha_light_target_processor.py index 505fa08..030dece 100644 --- a/server/src/ledgrab/core/processing/ha_light_target_processor.py +++ b/server/src/ledgrab/core/processing/ha_light_target_processor.py @@ -8,7 +8,7 @@ Rate-limited to update_rate Hz (typically 1-5 Hz). import asyncio import json import time -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Tuple import numpy as np @@ -33,13 +33,13 @@ class HALightTargetProcessor(TargetProcessor): brightness=None, # legacy compat brightness_value_source_id: str = "", - light_mappings: Optional[List[HALightMapping]] = None, + light_mappings: List[HALightMapping] | None = None, update_rate: float = 2.0, transition=None, min_brightness_threshold: int = 0, color_tolerance: int = 5, stop_action: str = "none", - ctx: Optional[TargetContext] = None, + ctx: TargetContext | None = None, ): from ledgrab.storage.bindable import BindableFloat, bfloat @@ -77,10 +77,10 @@ class HALightTargetProcessor(TargetProcessor): # Snapshot of entity states captured at start() — used by "restore" stop action self._captured_states: Dict[str, Any] = {} self._ws_clients: List[Any] = [] - self._start_time: Optional[float] = None + self._start_time: float | None = None @property - def device_id(self) -> Optional[str]: + def device_id(self) -> str | None: return None # HA light targets don't use device providers async def start(self) -> None: @@ -109,7 +109,7 @@ class HALightTargetProcessor(TargetProcessor): try: from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager - ha_manager: Optional[HomeAssistantManager] = getattr(self._ctx, "ha_manager", None) + ha_manager: HomeAssistantManager | None = getattr(self._ctx, "ha_manager", None) if ha_manager: self._ha_runtime = await ha_manager.acquire(self._ha_source_id) except Exception as e: diff --git a/server/src/ledgrab/core/processing/kc_color_strip_stream.py b/server/src/ledgrab/core/processing/kc_color_strip_stream.py index 64709cc..6b9f933 100644 --- a/server/src/ledgrab/core/processing/kc_color_strip_stream.py +++ b/server/src/ledgrab/core/processing/kc_color_strip_stream.py @@ -8,7 +8,7 @@ from __future__ import annotations import threading import time -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, List import numpy as np @@ -68,10 +68,10 @@ class KeyColorsColorStripStream(ColorStripStream): n = len(source.rectangles) self._led_count = n - self._latest_colors: Optional[np.ndarray] = None + self._latest_colors: np.ndarray | None = None self._colors_lock = threading.Lock() self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None # ── Public interface (matches ColorStripStream) ── @@ -87,7 +87,7 @@ class KeyColorsColorStripStream(ColorStripStream): def is_animated(self) -> bool: return True - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._colors_lock: return self._latest_colors @@ -132,7 +132,7 @@ class KeyColorsColorStripStream(ColorStripStream): def _processing_loop(self) -> None: """Background thread: capture → extract → smooth → cache.""" prev_capture = None - prev_colors_arr: Optional[np.ndarray] = None + prev_colors_arr: np.ndarray | None = None frame_time = 1.0 / max(1, self.target_fps) logger.info(f"KC CSS stream started: {self._source.id} ({len(self._rect_names)} rects)") diff --git a/server/src/ledgrab/core/processing/live_stream.py b/server/src/ledgrab/core/processing/live_stream.py index 64f1365..e53d6b7 100644 --- a/server/src/ledgrab/core/processing/live_stream.py +++ b/server/src/ledgrab/core/processing/live_stream.py @@ -15,7 +15,7 @@ share a single LiveStream instance. import threading import time from abc import ABC, abstractmethod -from typing import List, Optional +from typing import List import numpy as np @@ -49,7 +49,7 @@ class LiveStream(ABC): @property @abstractmethod - def display_index(self) -> Optional[int]: + def display_index(self) -> int | None: """Display index being captured, or None for non-capture streams.""" @abstractmethod @@ -61,7 +61,7 @@ class LiveStream(ABC): """Stop producing frames and release resources.""" @abstractmethod - def get_latest_frame(self) -> Optional[ScreenCapture]: + def get_latest_frame(self) -> ScreenCapture | None: """Get the most recent frame. Returns: @@ -114,17 +114,17 @@ class ScreenCaptureLiveStream(LiveStream): self._capture_stream = capture_stream self._fps = fps self._frame_time = 1.0 / fps if fps > 0 else 1.0 - self._latest_frame: Optional[ScreenCapture] = None + self._latest_frame: ScreenCapture | None = None self._frame_lock = threading.Lock() self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None @property def target_fps(self) -> int: return self._fps @property - def display_index(self) -> Optional[int]: + def display_index(self) -> int | None: return self._capture_stream.display_index def start(self) -> None: @@ -161,7 +161,7 @@ class ScreenCaptureLiveStream(LiveStream): f"ScreenCaptureLiveStream stopped " f"(display={self._capture_stream.display_index})" ) - def get_latest_frame(self) -> Optional[ScreenCapture]: + def get_latest_frame(self) -> ScreenCapture | None: with self._frame_lock: return self._latest_frame @@ -224,10 +224,10 @@ class ProcessedLiveStream(LiveStream): self._source = source self._filters = filters self._image_pool = ImagePool() - self._latest_frame: Optional[ScreenCapture] = None + self._latest_frame: ScreenCapture | None = None self._frame_lock = threading.Lock() self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None # True when at least one filter requests idle-tick processing (e.g. frame interpolation). # The processing loop then runs at 2× source rate and runs the full filter chain on idle # ticks so those filters can produce interpolated output. @@ -241,7 +241,7 @@ class ProcessedLiveStream(LiveStream): return base * 2 if self._has_idle_filters else base @property - def display_index(self) -> Optional[int]: + def display_index(self) -> int | None: return self._source.display_index def start(self) -> None: @@ -267,13 +267,13 @@ class ProcessedLiveStream(LiveStream): self._thread = None self._latest_frame = None - def get_latest_frame(self) -> Optional[ScreenCapture]: + def get_latest_frame(self) -> ScreenCapture | None: with self._frame_lock: return self._latest_frame def _process_loop(self) -> None: """Background thread: poll source, apply filters, cache result.""" - cached_source_frame: Optional[ScreenCapture] = None + cached_source_frame: ScreenCapture | None = None # Ring buffer: 5 slots gives a safety margin for the multi-consumer # case (multiple PictureColorStripStream/HA target threads can hold # the same _latest_frame reference while we wrap around). At 60 FPS @@ -281,10 +281,10 @@ class ProcessedLiveStream(LiveStream): # before the slot is overwritten — well above any realistic # extract→map→smooth tick. _RING_SIZE = 5 - _ring: List[Optional[np.ndarray]] = [None] * _RING_SIZE + _ring: List[np.ndarray | None] = [None] * _RING_SIZE _ring_idx = 0 # Separate buffer for idle-tick source copies (not part of the ring buffer) - _idle_src_buf: Optional[np.ndarray] = None + _idle_src_buf: np.ndarray | None = None fps = self.target_fps frame_time = 1.0 / fps if fps > 0 else 1.0 # Track the source's frame_id so we can wait event-driven for new @@ -395,7 +395,7 @@ class StaticImageLiveStream(LiveStream): return 1 @property - def display_index(self) -> Optional[int]: + def display_index(self) -> int | None: return None def start(self) -> None: @@ -404,5 +404,5 @@ class StaticImageLiveStream(LiveStream): def stop(self) -> None: pass - def get_latest_frame(self) -> Optional[ScreenCapture]: + def get_latest_frame(self) -> ScreenCapture | None: return self._frame diff --git a/server/src/ledgrab/core/processing/live_stream_manager.py b/server/src/ledgrab/core/processing/live_stream_manager.py index 42b47fe..f2b1d29 100644 --- a/server/src/ledgrab/core/processing/live_stream_manager.py +++ b/server/src/ledgrab/core/processing/live_stream_manager.py @@ -9,7 +9,7 @@ releases them. """ from dataclasses import dataclass -from typing import Dict, Optional +from typing import Dict import numpy as np @@ -41,7 +41,7 @@ class _LiveStreamEntry: ref_count: int # For ProcessedLiveStream: the source stream ID whose live stream we depend on. # Used to recursively release the source when this stream's ref count hits 0. - source_stream_id: Optional[str] = None + source_stream_id: str | None = None class LiveStreamManager: diff --git a/server/src/ledgrab/core/processing/mapped_stream.py b/server/src/ledgrab/core/processing/mapped_stream.py index 9e29d10..1f06b0a 100644 --- a/server/src/ledgrab/core/processing/mapped_stream.py +++ b/server/src/ledgrab/core/processing/mapped_stream.py @@ -3,7 +3,7 @@ import threading import time from collections import OrderedDict -from typing import Dict, List, Optional +from typing import Dict, List import numpy as np @@ -46,8 +46,8 @@ class MappedColorStripStream(ColorStripStream): self._frame_time: float = 1.0 / 30 self._running = False - self._thread: Optional[threading.Thread] = None - self._latest_colors: Optional[np.ndarray] = None + self._thread: threading.Thread | None = None + self._latest_colors: np.ndarray | None = None self._colors_lock = threading.Lock() # zone_index -> (source_id, consumer_id, stream) @@ -101,7 +101,7 @@ class MappedColorStripStream(ColorStripStream): self._release_sub_streams() logger.info(f"MappedColorStripStream stopped: {self._source_id}") - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._colors_lock: return self._latest_colors diff --git a/server/src/ledgrab/core/processing/math_wave_stream.py b/server/src/ledgrab/core/processing/math_wave_stream.py index b245501..5d1c284 100644 --- a/server/src/ledgrab/core/processing/math_wave_stream.py +++ b/server/src/ledgrab/core/processing/math_wave_stream.py @@ -8,7 +8,7 @@ combined value through a gradient palette for color output. import math import threading import time -from typing import List, Optional +from typing import List import numpy as np @@ -65,15 +65,15 @@ class MathWaveColorStripStream(ColorStripStream): def __init__(self, source): self._colors_lock = threading.Lock() self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._fps = 30 self._frame_time = 1.0 / 30 self._clock = None self._led_count = 1 self._auto_size = True self._gradient_store = None - self._gradient_lut: Optional[np.ndarray] = None - self._cached_gradient_id: Optional[str] = None + self._gradient_lut: np.ndarray | None = None + self._cached_gradient_id: str | None = None self._update_from_source(source) def _update_from_source(self, source) -> None: @@ -84,7 +84,7 @@ class MathWaveColorStripStream(ColorStripStream): raw_waves = getattr(source, "waves", None) or [] self._waves: List[dict] = raw_waves[:MAX_WAVE_LAYERS] with self._colors_lock: - self._colors: Optional[np.ndarray] = None + self._colors: np.ndarray | None = None # Invalidate LUT so it rebuilds on next frame self._cached_gradient_id = None @@ -130,7 +130,7 @@ class MathWaveColorStripStream(ColorStripStream): self._thread = None logger.info("MathWaveColorStripStream stopped") - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._colors_lock: return self._colors @@ -180,7 +180,7 @@ class MathWaveColorStripStream(ColorStripStream): _pool_n = 0 _buf_a = _buf_b = None _use_a = True - _positions: Optional[np.ndarray] = None # normalised LED positions [0,1] + _positions: np.ndarray | None = None # normalised LED positions [0,1] try: with high_resolution_timer(): diff --git a/server/src/ledgrab/core/processing/metric_readers.py b/server/src/ledgrab/core/processing/metric_readers.py index c9f82c0..4d490f5 100644 --- a/server/src/ledgrab/core/processing/metric_readers.py +++ b/server/src/ledgrab/core/processing/metric_readers.py @@ -26,7 +26,7 @@ from __future__ import annotations import time from dataclasses import dataclass -from typing import TYPE_CHECKING, Callable, Dict, Optional +from typing import TYPE_CHECKING, Callable, Dict from ledgrab.utils import get_logger @@ -54,7 +54,7 @@ class MetricSpec: read_psutil: ReaderFn read_fallback: ReaderFn normalize: NormalizeFn - prime: Optional[PrimeFn] = None + prime: PrimeFn | None = None # --------------------------------------------------------------------------- @@ -266,6 +266,6 @@ METRIC_SPECS: Dict[str, MetricSpec] = { } -def get_spec(metric: str) -> Optional[MetricSpec]: +def get_spec(metric: str) -> MetricSpec | None: """Look up the spec for ``metric``, returning ``None`` for unknown names.""" return METRIC_SPECS.get(metric) diff --git a/server/src/ledgrab/core/processing/metrics_history.py b/server/src/ledgrab/core/processing/metrics_history.py index d067836..96de6df 100644 --- a/server/src/ledgrab/core/processing/metrics_history.py +++ b/server/src/ledgrab/core/processing/metrics_history.py @@ -4,7 +4,7 @@ import asyncio import os from collections import deque from datetime import datetime, timezone -from typing import Dict, Optional +from typing import Dict from ledgrab.utils import get_logger from ledgrab.utils.gpu import ( @@ -74,17 +74,17 @@ class MetricsHistory: self._manager = processor_manager self._system: deque = deque(maxlen=MAX_SAMPLES) self._targets: Dict[str, deque] = {} - self._task: Optional[asyncio.Task] = None + self._task: asyncio.Task | None = None # Baselines for converting cumulative `errors_count` / # `frames_skipped` into per-second rates inside the system ring # buffer. None until the first sample arrives so we don't # synthesize a fake initial spike from "0 → live count". - self._prev_total_errors: Optional[int] = None - self._prev_total_skipped: Optional[int] = None + self._prev_total_errors: int | None = None + self._prev_total_skipped: int | None = None # Same shape, but for the network throughput counter. Reset to # None when the cumulative sum drops (target stopped, counter # reset) so we never emit a negative rate. - self._prev_total_bytes_sent: Optional[int] = None + self._prev_total_bytes_sent: int | None = None async def start(self): """Start the background sampling loop.""" @@ -225,8 +225,8 @@ class MetricsHistory: # device-health view rather than re-deriving from per-target # state, so devices that are shared by multiple targets only # count once. - device_latency_avg_ms: Optional[float] = None - device_latency_max_ms: Optional[float] = None + device_latency_avg_ms: float | None = None + device_latency_max_ms: float | None = None device_online_count = 0 device_total_count = 0 try: diff --git a/server/src/ledgrab/core/processing/notification_stream.py b/server/src/ledgrab/core/processing/notification_stream.py index 3ee72a0..a38efac 100644 --- a/server/src/ledgrab/core/processing/notification_stream.py +++ b/server/src/ledgrab/core/processing/notification_stream.py @@ -13,7 +13,6 @@ import collections import math import threading import time -from typing import Optional import numpy as np @@ -53,7 +52,7 @@ class NotificationColorStripStream(ColorStripStream): def __init__(self, source): self._colors_lock = threading.Lock() self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._fps = 30 self._frame_time = 1.0 / 30 @@ -61,7 +60,7 @@ class NotificationColorStripStream(ColorStripStream): self._event_queue: collections.deque = collections.deque(maxlen=16) # Active effect state - self._active_effect: Optional[dict] = None # {"color": (r,g,b), "start": float} + self._active_effect: dict | None = None # {"color": (r,g,b), "start": float} # Asset store for resolving sound file paths (injected via set_asset_store) self._asset_store = None @@ -94,7 +93,7 @@ class NotificationColorStripStream(ColorStripStream): raw_app_sounds = dict(getattr(source, "app_sounds", {})) self._app_sounds = {k.lower(): v for k, v in raw_app_sounds.items()} with self._colors_lock: - self._colors: Optional[np.ndarray] = np.zeros((self._led_count, 3), dtype=np.uint8) + self._colors: np.ndarray | None = np.zeros((self._led_count, 3), dtype=np.uint8) def fire(self, app_name: str = None, color_override: str = None) -> bool: """Trigger a notification effect. Thread-safe. @@ -135,7 +134,7 @@ class NotificationColorStripStream(ColorStripStream): return True - def _play_notification_sound(self, app_lower: Optional[str]) -> None: + def _play_notification_sound(self, app_lower: str | None) -> None: """Resolve and play the notification sound for the given app.""" if self._asset_store is None: return @@ -225,7 +224,7 @@ class NotificationColorStripStream(ColorStripStream): self._thread = None logger.info("NotificationColorStripStream stopped") - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._colors_lock: return self._colors diff --git a/server/src/ledgrab/core/processing/os_notification_listener.py b/server/src/ledgrab/core/processing/os_notification_listener.py index 5e0d985..27f1280 100644 --- a/server/src/ledgrab/core/processing/os_notification_listener.py +++ b/server/src/ledgrab/core/processing/os_notification_listener.py @@ -84,7 +84,7 @@ class _WindowsBackend: def __init__(self, on_notification): self._on_notification = on_notification self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._seen_ids: Set[int] = set() @staticmethod @@ -172,7 +172,7 @@ class _WindowsBackend: self._running = False @staticmethod - def _extract_app_name(notification) -> Optional[str]: + def _extract_app_name(notification) -> str | None: try: return notification.app_info.display_info.display_name except (OSError, AttributeError): @@ -188,7 +188,7 @@ class _LinuxBackend: def __init__(self, on_notification): self._on_notification = on_notification self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None @staticmethod def probe() -> bool: @@ -358,7 +358,7 @@ class OsNotificationListener: except Exception as exc: logger.warning(f"Failed to save notification history: {exc}") - def _on_new_notification(self, app_name: Optional[str]) -> None: + def _on_new_notification(self, app_name: str | None) -> None: """Handle a new OS notification — fire matching streams.""" from ledgrab.storage.color_strip_source import NotificationColorStripSource diff --git a/server/src/ledgrab/core/processing/processed_stream.py b/server/src/ledgrab/core/processing/processed_stream.py index 2167da5..39a9f08 100644 --- a/server/src/ledgrab/core/processing/processed_stream.py +++ b/server/src/ledgrab/core/processing/processed_stream.py @@ -2,7 +2,6 @@ import threading import time -from typing import Optional import numpy as np @@ -33,7 +32,7 @@ class ProcessedColorStripStream(ColorStripStream): self._source = source self._css_manager = css_manager self._cspt_store = cspt_store - self._input_stream: Optional[ColorStripStream] = None + self._input_stream: ColorStripStream | None = None # Unique per instance so concurrent consumers don't collide on # sub-stream consumer IDs (e.g. two preview WS connections against # the same processed source). @@ -42,8 +41,8 @@ class ProcessedColorStripStream(ColorStripStream): self._filters = [] self._cached_template_id = None self._running = False - self._thread: Optional[threading.Thread] = None - self._colors: Optional[np.ndarray] = None + self._thread: threading.Thread | None = None + self._colors: np.ndarray | None = None self._colors_lock = threading.Lock() self._led_count = 0 self._auto_size = True @@ -104,7 +103,7 @@ class ProcessedColorStripStream(ColorStripStream): self._input_stream = None logger.info(f"ProcessedColorStripStream stopped for {self._source.id}") - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._colors_lock: return self._colors diff --git a/server/src/ledgrab/core/processing/processor_manager.py b/server/src/ledgrab/core/processing/processor_manager.py index 773e99f..65967d4 100644 --- a/server/src/ledgrab/core/processing/processor_manager.py +++ b/server/src/ledgrab/core/processing/processor_manager.py @@ -3,7 +3,7 @@ import asyncio import time from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Tuple import httpx @@ -58,24 +58,24 @@ class ProcessorDependencies: Keeps the constructor signature stable when new stores are added. """ - picture_source_store: Optional[PictureSourceStore] = None - capture_template_store: Optional[TemplateStore] = None - pp_template_store: Optional[PostprocessingTemplateStore] = None - device_store: Optional[DeviceStore] = None - color_strip_store: Optional[ColorStripStore] = None - audio_source_store: Optional[AudioSourceStore] = None - audio_template_store: Optional[AudioTemplateStore] = None - value_source_store: Optional[ValueSourceStore] = None - sync_clock_manager: Optional[SyncClockManager] = None - cspt_store: Optional[ColorStripProcessingTemplateStore] = None - gradient_store: Optional[GradientStore] = None - weather_manager: Optional[WeatherManager] = None - asset_store: Optional[AssetStore] = None - ha_manager: Optional[Any] = None # HomeAssistantManager - mqtt_manager: Optional[Any] = None # MQTTManager - game_event_bus: Optional[Any] = None # GameEventBus - audio_processing_template_store: Optional[Any] = None # AudioProcessingTemplateStore - http_endpoint_store: Optional[HTTPEndpointStore] = None + picture_source_store: PictureSourceStore | None = None + capture_template_store: TemplateStore | None = None + pp_template_store: PostprocessingTemplateStore | None = None + device_store: DeviceStore | None = None + color_strip_store: ColorStripStore | None = None + audio_source_store: AudioSourceStore | None = None + audio_template_store: AudioTemplateStore | None = None + value_source_store: ValueSourceStore | None = None + sync_clock_manager: SyncClockManager | None = None + cspt_store: ColorStripProcessingTemplateStore | None = None + gradient_store: GradientStore | None = None + weather_manager: WeatherManager | None = None + asset_store: AssetStore | None = None + ha_manager: Any | None = None # HomeAssistantManager + mqtt_manager: Any | None = None # MQTTManager + game_event_bus: Any | None = None # GameEventBus + audio_processing_template_store: Any | None = None # AudioProcessingTemplateStore + http_endpoint_store: HTTPEndpointStore | None = None @dataclass @@ -86,20 +86,20 @@ class DeviceState: device_url: str led_count: int device_type: str = "wled" - baud_rate: Optional[int] = None + baud_rate: int | None = None health: DeviceHealth = field(default_factory=DeviceHealth) - health_task: Optional[asyncio.Task] = None + health_task: asyncio.Task | None = None # Software brightness for devices without hardware brightness (e.g. Adalight) software_brightness: int = 255 # Cached hardware brightness (fetched once, updated via SET; avoids polling device) - hardware_brightness: Optional[int] = None + hardware_brightness: int | None = None # Auto-restore: restore device to idle state when targets stop auto_shutdown: bool = False # Calibration test mode (works independently of target processing) test_mode_active: bool = False test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict) # Calibration used for the current test (from the CSS being tested) - test_calibration: Optional[CalibrationConfig] = None + test_calibration: CalibrationConfig | None = None # Tracked power state for serial devices (no hardware query) power_on: bool = True # OpenRGB zone mode: "combined" or "separate" @@ -126,7 +126,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) self._processors: Dict[str, TargetProcessor] = {} self._idle_clients: Dict[str, object] = {} # device_id -> cached LEDClient self._health_monitoring_active = False - self._http_client: Optional[httpx.AsyncClient] = None + self._http_client: httpx.AsyncClient | None = None self._picture_source_store = deps.picture_source_store self._capture_template_store = deps.capture_template_store self._pp_template_store = deps.pp_template_store @@ -191,7 +191,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) return self._audio_capture_manager @property - def value_stream_manager(self) -> Optional[ValueStreamManager]: + def value_stream_manager(self) -> ValueStreamManager | None: return self._value_stream_manager @property @@ -266,7 +266,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) device_url: str, led_count: int, device_type: str = "wled", - baud_rate: Optional[int] = None, + baud_rate: int | None = None, software_brightness: int = 255, auto_shutdown: bool = False, zone_mode: str = "combined", @@ -313,9 +313,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) def update_device_info( self, device_id: str, - device_url: Optional[str] = None, - led_count: Optional[int] = None, - baud_rate: Optional[int] = None, + device_url: str | None = None, + led_count: int | None = None, + baud_rate: int | None = None, ): """Update device connection info.""" if device_id not in self._devices: @@ -740,7 +740,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) return True return False - def get_processing_target_for_device(self, device_id: str) -> Optional[str]: + def get_processing_target_for_device(self, device_id: str) -> str | None: """Get the target_id that is currently processing for a device.""" for proc in self._processors.values(): if proc.device_id == device_id and proc.is_running: @@ -949,11 +949,11 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) """Check if a device is registered.""" return device_id in self._devices - def find_device_state(self, device_id: str) -> Optional[DeviceState]: + def find_device_state(self, device_id: str) -> DeviceState | None: """Get device state, returning None if not registered.""" return self._devices.get(device_id) - def get_processor(self, target_id: str) -> Optional[TargetProcessor]: + def get_processor(self, target_id: str) -> TargetProcessor | None: """Look up a processor by target_id, returning None if not found.""" return self._processors.get(target_id) diff --git a/server/src/ledgrab/core/processing/sync_clock_manager.py b/server/src/ledgrab/core/processing/sync_clock_manager.py index 54928fa..afa96c4 100644 --- a/server/src/ledgrab/core/processing/sync_clock_manager.py +++ b/server/src/ledgrab/core/processing/sync_clock_manager.py @@ -5,7 +5,7 @@ destroyed when the last consumer releases it. """ import threading -from typing import Dict, Optional +from typing import Dict from ledgrab.core.processing.sync_clock_runtime import SyncClockRuntime from ledgrab.storage.sync_clock_store import SyncClockStore @@ -66,7 +66,7 @@ class SyncClockManager: # ── Lookup (no ref counting) ────────────────────────────────── - def get_runtime(self, clock_id: str) -> Optional[SyncClockRuntime]: + def get_runtime(self, clock_id: str) -> SyncClockRuntime | None: """Return an existing runtime or *None* (does not create one).""" with self._lock: return self._runtimes.get(clock_id) diff --git a/server/src/ledgrab/core/processing/target_processor.py b/server/src/ledgrab/core/processing/target_processor.py index ecb6602..0aa6174 100644 --- a/server/src/ledgrab/core/processing/target_processor.py +++ b/server/src/ledgrab/core/processing/target_processor.py @@ -14,7 +14,7 @@ import asyncio from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple +from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple from ledgrab.utils import get_logger @@ -47,12 +47,12 @@ class ProcessingMetrics: frames_skipped: int = 0 frames_keepalive: int = 0 errors_count: int = 0 - last_error: Optional[str] = None - last_update: Optional[datetime] = None + last_error: str | None = None + last_update: datetime | None = None last_update_mono: float = ( 0.0 # monotonic timestamp for hot-path; lazily converted to last_update on read ) - start_time: Optional[datetime] = None + start_time: datetime | None = None fps_actual: float = 0.0 fps_potential: float = 0.0 fps_current: int = 0 @@ -67,7 +67,7 @@ class ProcessingMetrics: timing_calc_colors_ms: float = 0.0 timing_broadcast_ms: float = 0.0 # Streaming liveness (HTTP probe during DDP) - device_streaming_reachable: Optional[bool] = None + device_streaming_reachable: bool | None = None fps_effective: int = 0 # Cumulative LED-payload bytes sent to the device. Aggregated across # all running targets in MetricsHistory to derive a per-second @@ -87,17 +87,17 @@ class TargetContext: live_stream_manager: "LiveStreamManager" overlay_manager: "OverlayManager" - picture_source_store: Optional["PictureSourceStore"] = None - capture_template_store: Optional["TemplateStore"] = None - pp_template_store: Optional["PostprocessingTemplateStore"] = None - device_store: Optional["DeviceStore"] = None - color_strip_stream_manager: Optional["ColorStripStreamManager"] = None - value_stream_manager: Optional["ValueStreamManager"] = None - cspt_store: Optional["ColorStripProcessingTemplateStore"] = None + picture_source_store: "PictureSourceStore" | None = None + capture_template_store: "TemplateStore" | None = None + pp_template_store: "PostprocessingTemplateStore" | None = None + device_store: "DeviceStore" | None = None + color_strip_stream_manager: "ColorStripStreamManager" | None = None + value_stream_manager: "ValueStreamManager" | None = None + cspt_store: "ColorStripProcessingTemplateStore" | None = None fire_event: Callable[[dict], None] = lambda e: None is_test_mode_active: Callable[[str], bool] = lambda _: False - ha_manager: Optional[Any] = None # HomeAssistantManager (avoid circular import) - mqtt_manager: Optional[Any] = None # MQTTManager (avoid circular import) + ha_manager: Any | None = None # HomeAssistantManager (avoid circular import) + mqtt_manager: Any | None = None # MQTTManager (avoid circular import) # --------------------------------------------------------------------------- @@ -116,7 +116,7 @@ class TargetProcessor(ABC): self._picture_source_id = picture_source_id self._ctx = ctx self._is_running = False - self._task: Optional[asyncio.Task] = None + self._task: asyncio.Task | None = None self._metrics = ProcessingMetrics() # ----- Properties ----- @@ -205,7 +205,7 @@ class TargetProcessor(ABC): # ----- Device / display info (overridden by device-aware subclasses) ----- @property - def device_id(self) -> Optional[str]: + def device_id(self) -> str | None: """Device ID this processor streams to, or None.""" return None @@ -214,7 +214,7 @@ class TargetProcessor(ABC): """Active LED client, or None.""" return None - def get_display_index(self) -> Optional[int]: + def get_display_index(self) -> int | None: """Display index being captured, or None.""" return None @@ -236,7 +236,7 @@ class TargetProcessor(ABC): """Whether this target supports screen overlay visualization.""" return False - async def start_overlay(self, target_name: Optional[str] = None) -> None: + async def start_overlay(self, target_name: str | None = None) -> None: """Start overlay visualization (if supported).""" raise NotImplementedError(f"{type(self).__name__} does not support overlays") diff --git a/server/src/ledgrab/core/processing/value_kinds.py b/server/src/ledgrab/core/processing/value_kinds.py index 91c6d61..62c3d4c 100644 --- a/server/src/ledgrab/core/processing/value_kinds.py +++ b/server/src/ledgrab/core/processing/value_kinds.py @@ -19,7 +19,7 @@ deps from their own context. from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Callable, Optional +from typing import TYPE_CHECKING, Any, Callable if TYPE_CHECKING: # Typed forward references so mypy/pyright catch typos like @@ -66,17 +66,17 @@ class ValueStreamDeps: """ value_stream_manager: "Any" - audio_capture_manager: Optional["AudioCaptureManager"] = None - audio_source_store: Optional["AudioSourceStore"] = None - audio_template_store: Optional["AudioTemplateStore"] = None - audio_processing_template_store: Optional["AudioProcessingTemplateStore"] = None - live_stream_manager: Optional["LiveStreamManager"] = None - ha_manager: Optional["HomeAssistantManager"] = None - gradient_store: Optional["GradientStore"] = None - css_stream_manager: Optional["ColorStripStreamManager"] = None - event_bus: Optional["GameEventBus"] = None - http_endpoint_store: Optional["HTTPEndpointStore"] = None - clock_runtime: Optional["SyncClockRuntime"] = None + audio_capture_manager: "AudioCaptureManager" | None = None + audio_source_store: "AudioSourceStore" | None = None + audio_template_store: "AudioTemplateStore" | None = None + audio_processing_template_store: "AudioProcessingTemplateStore" | None = None + live_stream_manager: "LiveStreamManager" | None = None + ha_manager: "HomeAssistantManager" | None = None + gradient_store: "GradientStore" | None = None + css_stream_manager: "ColorStripStreamManager" | None = None + event_bus: "GameEventBus" | None = None + http_endpoint_store: "HTTPEndpointStore" | None = None + clock_runtime: "SyncClockRuntime" | None = None # --------------------------------------------------------------------------- diff --git a/server/src/ledgrab/core/processing/value_stream.py b/server/src/ledgrab/core/processing/value_stream.py index e05ab29..7790615 100644 --- a/server/src/ledgrab/core/processing/value_stream.py +++ b/server/src/ledgrab/core/processing/value_stream.py @@ -27,7 +27,7 @@ import re import time from abc import ABC, abstractmethod from datetime import datetime -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Tuple import numpy as np @@ -185,8 +185,8 @@ class AudioValueStream(ValueStream): min_value: float = 0.0, max_value: float = 1.0, auto_gain: bool = False, - audio_capture_manager: Optional["AudioCaptureManager"] = None, - audio_source_store: Optional["AudioSourceStore"] = None, + audio_capture_manager: "AudioCaptureManager" | None = None, + audio_source_store: "AudioSourceStore" | None = None, audio_template_store=None, audio_processing_template_store=None, ): @@ -489,7 +489,7 @@ class SceneValueStream(ValueStream): smoothing: float = 0.3, min_value: float = 0.0, max_value: float = 1.0, - live_stream_manager: Optional["LiveStreamManager"] = None, + live_stream_manager: "LiveStreamManager" | None = None, ): self._picture_source_id = picture_source_id self._behavior = scene_behavior @@ -626,7 +626,7 @@ class DaylightValueStream(ValueStream): # Cache: (sr_min, ss_min) → LUT, mirroring DaylightColorStripStream self._lut_cache: Dict[Tuple[int, int], np.ndarray] = {} - def _resolve_lut(self, day_of_year: Optional[int], utc_offset_hours: float) -> np.ndarray: + def _resolve_lut(self, day_of_year: int | None, utc_offset_hours: float) -> np.ndarray: if day_of_year is None: return self._default_lut from ledgrab.core.processing.daylight_stream import ( @@ -904,7 +904,7 @@ class HAEntityValueStream(ValueStream): min_ha_value: float = 0.0, max_ha_value: float = 100.0, smoothing: float = 0.0, - ha_manager: Optional[Any] = None, + ha_manager: Any | None = None, ): self._ha_source_id = ha_source_id self._entity_id = entity_id @@ -913,8 +913,8 @@ class HAEntityValueStream(ValueStream): self._max_ha = max_ha_value self._smoothing = smoothing self._ha_manager = ha_manager - self._prev_value: Optional[float] = None - self._raw_value: Optional[float] = None + self._prev_value: float | None = None + self._raw_value: float | None = None def start(self) -> None: if self._ha_manager and self._ha_source_id: @@ -992,7 +992,7 @@ class HAEntityValueStream(ValueStream): self._prev_value = normalized return normalized - def get_raw_value(self) -> Optional[float]: + def get_raw_value(self) -> float | None: """Return the last raw HA entity value before normalization.""" return self._raw_value @@ -1052,7 +1052,7 @@ class HTTPValueStream(ValueStream): min_value: float, max_value: float, smoothing: float, - http_endpoint_store: Optional["HTTPEndpointStore"] = None, + http_endpoint_store: "HTTPEndpointStore" | None = None, ) -> None: self._endpoint_id = endpoint_id self._json_path = json_path @@ -1061,14 +1061,14 @@ class HTTPValueStream(ValueStream): self._max_value = max_value self._smoothing = smoothing self._http_endpoint_store = http_endpoint_store - self._task: Optional[asyncio.Task] = None + self._task: asyncio.Task | None = None self._raw_value: Any = None - self._prev_normalized: Optional[float] = None + self._prev_normalized: float | None = None # Kept as private attrs for internal/log diagnostics; not exposed via # public properties or API until a status endpoint consumes them. - self._last_fetched_at: Optional[datetime] = None - self._last_status_code: Optional[int] = None - self._last_error: Optional[str] = None + self._last_fetched_at: datetime | None = None + self._last_status_code: int | None = None + self._last_error: str | None = None def start(self) -> None: if self._task is not None: @@ -1249,15 +1249,15 @@ class GradientMapValueStream(ValueStream): value_source_id: str, gradient_id: str = "", easing: str = "linear", - value_stream_manager: Optional["ValueStreamManager"] = None, - gradient_store: Optional[Any] = None, + value_stream_manager: "ValueStreamManager" | None = None, + gradient_store: Any | None = None, ): self._value_source_id = value_source_id self._gradient_id = gradient_id self._easing = easing self._vsm = value_stream_manager self._gradient_store = gradient_store - self._inner_stream: Optional[ValueStream] = None + self._inner_stream: ValueStream | None = None self._stops: list = [] self._input_value: float = 0.0 self._resolve_gradient() @@ -1384,7 +1384,7 @@ class CSSExtractValueStream(ValueStream): color_strip_source_id: str, led_start: int = 0, led_end: int = -1, - css_stream_manager: Optional["ColorStripStreamManager"] = None, + css_stream_manager: "ColorStripStreamManager" | None = None, ): self._css_source_id = color_strip_source_id self._led_start = led_start @@ -1510,12 +1510,12 @@ class SystemMetricsValueStream(ValueStream): self._sensor_label = sensor_label self._poll_interval = max(0.1, poll_interval) self._smoothing = smoothing - self._prev_value: Optional[float] = None - self._raw_value: Optional[float] = None + self._prev_value: float | None = None + self._raw_value: float | None = None self._last_poll: float = 0.0 # Network delta tracking - self._prev_net_bytes: Optional[int] = None - self._prev_net_time: Optional[float] = None + self._prev_net_bytes: int | None = None + self._prev_net_time: float | None = None # GPU unavailable flag (avoid repeated warnings) self._gpu_unavailable = False # psutil may be unavailable on Android @@ -1558,7 +1558,7 @@ class SystemMetricsValueStream(ValueStream): self._prev_value = normalized return normalized - def get_raw_value(self) -> Optional[float]: + def get_raw_value(self) -> float | None: """Return the last raw metric value before normalization.""" return self._raw_value @@ -1618,17 +1618,17 @@ class ValueStreamManager: def __init__( self, value_source_store: "ValueSourceStore", - audio_capture_manager: Optional["AudioCaptureManager"] = None, - audio_source_store: Optional["AudioSourceStore"] = None, - live_stream_manager: Optional["LiveStreamManager"] = None, + audio_capture_manager: "AudioCaptureManager" | None = None, + audio_source_store: "AudioSourceStore" | None = None, + live_stream_manager: "LiveStreamManager" | None = None, audio_template_store=None, - ha_manager: Optional["HomeAssistantManager"] = None, - css_stream_manager: Optional["ColorStripStreamManager"] = None, - gradient_store: Optional[Any] = None, - event_bus: Optional["GameEventBus"] = None, + ha_manager: "HomeAssistantManager" | None = None, + css_stream_manager: "ColorStripStreamManager" | None = None, + gradient_store: Any | None = None, + event_bus: "GameEventBus" | None = None, audio_processing_template_store=None, - sync_clock_manager: Optional["SyncClockManager"] = None, - http_endpoint_store: Optional["HTTPEndpointStore"] = None, + sync_clock_manager: "SyncClockManager" | None = None, + http_endpoint_store: "HTTPEndpointStore" | None = None, ): self._value_source_store = value_source_store self._audio_capture_manager = audio_capture_manager @@ -1685,7 +1685,7 @@ class ValueStreamManager: else: logger.info(f"Released ref for value stream {vs_id} (refs={refs})") - def peek(self, vs_id: str) -> Optional[ValueStream]: + def peek(self, vs_id: str) -> ValueStream | None: """Read-only accessor: return the running ValueStream for ``vs_id`` if one exists, else ``None``. @@ -1792,7 +1792,7 @@ class ValueStreamManager: self._ref_counts.clear() logger.info("Released all value streams") - def _create_stream(self, source: "ValueSource", vs_id: Optional[str] = None) -> ValueStream: + def _create_stream(self, source: "ValueSource", vs_id: str | None = None) -> ValueStream: """Build a ValueStream for *source* via the central kind registry. The 14-branch ``isinstance`` ladder this method used to host was the diff --git a/server/src/ledgrab/core/processing/video_stream.py b/server/src/ledgrab/core/processing/video_stream.py index b311c25..c947bf5 100644 --- a/server/src/ledgrab/core/processing/video_stream.py +++ b/server/src/ledgrab/core/processing/video_stream.py @@ -7,7 +7,6 @@ Optional sync clock integration for frame-accurate seeking. import re import threading import time -from typing import Optional import numpy as np @@ -64,7 +63,7 @@ def _assert_video_url_allowed(url: str) -> None: ) -def resolve_youtube_url(url: str, resolution_limit: Optional[int] = None) -> str: +def resolve_youtube_url(url: str, resolution_limit: int | None = None) -> str: """Resolve a YouTube URL to a direct stream URL using yt-dlp.""" try: import yt_dlp @@ -109,7 +108,7 @@ def _require_cv2(): ) -def extract_thumbnail(url: str, resolution_limit: Optional[int] = None) -> Optional[np.ndarray]: +def extract_thumbnail(url: str, resolution_limit: int | None = None) -> np.ndarray | None: """Extract the first frame of a video as a thumbnail (RGB numpy array). For YouTube URLs, resolves via yt-dlp first. @@ -165,15 +164,15 @@ class VideoCaptureLiveStream(LiveStream): url: str, loop: bool = True, playback_speed: float = 1.0, - start_time: Optional[float] = None, - end_time: Optional[float] = None, - resolution_limit: Optional[int] = None, + start_time: float | None = None, + end_time: float | None = None, + resolution_limit: int | None = None, target_fps: int = 30, ): _require_cv2() super().__init__() self._original_url = url - self._resolved_url: Optional[str] = None + self._resolved_url: str | None = None self._loop = loop self._playback_speed = playback_speed self._start_time = start_time or 0.0 @@ -188,10 +187,10 @@ class VideoCaptureLiveStream(LiveStream): self._video_width: int = 0 self._video_height: int = 0 - self._latest_frame: Optional[ScreenCapture] = None + self._latest_frame: ScreenCapture | None = None self._frame_lock = threading.Lock() self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None # Sync clock (set externally) self._clock = None @@ -201,7 +200,7 @@ class VideoCaptureLiveStream(LiveStream): return self._target_fps @property - def display_index(self) -> Optional[int]: + def display_index(self) -> int | None: return None # Not a screen capture def set_clock(self, clock) -> None: @@ -275,7 +274,7 @@ class VideoCaptureLiveStream(LiveStream): self._latest_frame = None logger.info(f"VideoCaptureLiveStream stopped: {self._original_url}") - def get_latest_frame(self) -> Optional[ScreenCapture]: + def get_latest_frame(self) -> ScreenCapture | None: with self._frame_lock: return self._latest_frame @@ -297,7 +296,7 @@ class VideoCaptureLiveStream(LiveStream): frame_time = 1.0 / self._target_fps if self._target_fps > 0 else 1.0 / 30 playback_start = time.perf_counter() last_seek_time = -1.0 - buf: Optional[np.ndarray] = None + buf: np.ndarray | None = None consecutive_errors = 0 try: diff --git a/server/src/ledgrab/core/processing/weather_stream.py b/server/src/ledgrab/core/processing/weather_stream.py index 8ecb5d0..971b0e1 100644 --- a/server/src/ledgrab/core/processing/weather_stream.py +++ b/server/src/ledgrab/core/processing/weather_stream.py @@ -3,7 +3,6 @@ import random import threading import time -from typing import Optional import numpy as np @@ -93,7 +92,7 @@ class WeatherColorStripStream(ColorStripStream): self._weather_source_id: str = source.weather_source_id self._speed: float = bfloat(source.speed, 1.0) self._temperature_influence: float = bfloat(source.temperature_influence, 0.5) - self._clock_id: Optional[str] = source.clock_id + self._clock_id: str | None = source.clock_id self._weather_manager = weather_manager self._led_count: int = 0 # auto-size from device @@ -101,13 +100,13 @@ class WeatherColorStripStream(ColorStripStream): self._frame_time: float = 1.0 / 30 self._running = False - self._thread: Optional[threading.Thread] = None - self._latest_colors: Optional[np.ndarray] = None + self._thread: threading.Thread | None = None + self._latest_colors: np.ndarray | None = None self._colors_lock = threading.Lock() # Pre-allocated buffers - self._buf_a: Optional[np.ndarray] = None - self._buf_b: Optional[np.ndarray] = None + self._buf_a: np.ndarray | None = None + self._buf_b: np.ndarray | None = None self._use_a = True self._pool_n = 0 @@ -168,7 +167,7 @@ class WeatherColorStripStream(ColorStripStream): logger.info(f"WeatherColorStripStream stopped: {self._source_id}") - def get_latest_colors(self) -> Optional[np.ndarray]: + def get_latest_colors(self) -> np.ndarray | None: with self._colors_lock: return self._latest_colors @@ -206,7 +205,7 @@ class WeatherColorStripStream(ColorStripStream): self._buf_a = np.zeros((n, 3), dtype=np.uint8) self._buf_b = np.zeros((n, 3), dtype=np.uint8) - def _get_clock_time(self) -> Optional[float]: + def _get_clock_time(self) -> float | None: """Get time from sync clock if configured.""" if not self._clock_id: return None diff --git a/server/src/ledgrab/core/processing/wled_target_processor.py b/server/src/ledgrab/core/processing/wled_target_processor.py index ab2322b..6fad563 100644 --- a/server/src/ledgrab/core/processing/wled_target_processor.py +++ b/server/src/ledgrab/core/processing/wled_target_processor.py @@ -6,7 +6,6 @@ import asyncio import collections import time from datetime import datetime, timezone -from typing import Optional import httpx import numpy as np @@ -85,18 +84,18 @@ class WledTargetProcessor(TargetProcessor): # Adaptive FPS / liveness probe runtime state self._effective_fps: int = self._target_fps - self._device_reachable: Optional[bool] = None # None = not yet probed + self._device_reachable: bool | None = None # None = not yet probed # Runtime state (populated on start) - self._led_client: Optional[LEDClient] = None - self._css_stream: Optional[object] = None # active stream reference + self._led_client: LEDClient | None = None + self._css_stream: object | None = None # active stream reference self._value_stream = None # active brightness value stream - self._device_state_before: Optional[dict] = None + self._device_state_before: dict | None = None self._overlay_active = False self._needs_keepalive = True self._effective_led_count: int = 0 - self._resolved_display_index: Optional[int] = None + self._resolved_display_index: int | None = None self._device_config = None # populated on start(), typed DeviceConfig # Fit-to-device cache (per-instance to avoid cross-target thrash). @@ -104,13 +103,13 @@ class WledTargetProcessor(TargetProcessor): # and reusable scratch buffers so the per-frame interpolation runs # entirely with in-place numpy ops — no allocations. self._fit_cache_key: tuple = (0, 0) - self._fit_floor_idx: Optional[np.ndarray] = None - self._fit_ceil_idx: Optional[np.ndarray] = None - self._fit_frac: Optional[np.ndarray] = None - self._fit_left_u8: Optional[np.ndarray] = None - self._fit_right_u8: Optional[np.ndarray] = None - self._fit_blend_f32: Optional[np.ndarray] = None - self._fit_result_buf: Optional[np.ndarray] = None + self._fit_floor_idx: np.ndarray | None = None + self._fit_ceil_idx: np.ndarray | None = None + self._fit_frac: np.ndarray | None = None + self._fit_left_u8: np.ndarray | None = None + self._fit_right_u8: np.ndarray | None = None + self._fit_blend_f32: np.ndarray | None = None + self._fit_result_buf: np.ndarray | None = None # LED preview WebSocket clients self._preview_clients: list = [] @@ -126,7 +125,7 @@ class WledTargetProcessor(TargetProcessor): return self._device_id @property - def led_client(self) -> Optional[LEDClient]: + def led_client(self) -> LEDClient | None: return self._led_client # ----- Lifecycle ----- @@ -474,7 +473,7 @@ class WledTargetProcessor(TargetProcessor): await asyncio.sleep(chunk) slept += chunk - def get_display_index(self) -> Optional[int]: + def get_display_index(self) -> int | None: """Display index being captured, from the active stream.""" if self._resolved_display_index is not None: return self._resolved_display_index @@ -489,8 +488,8 @@ class WledTargetProcessor(TargetProcessor): fps_target = self._target_fps css_timing: dict = {} - css_capture_fps: Optional[int] = None - css_capture_fps_actual: Optional[float] = None + css_capture_fps: int | None = None + css_capture_fps_actual: float | None = None if self._is_running and self._css_stream is not None: css_timing = self._css_stream.get_last_timing() css_capture_fps = getattr(self._css_stream, "target_fps", None) @@ -600,7 +599,7 @@ class WledTargetProcessor(TargetProcessor): return True async def start_overlay( - self, target_name: Optional[str] = None, calibration=None, display_info=None + self, target_name: str | None = None, calibration=None, display_info=None ) -> None: if self._overlay_active: raise RuntimeError(f"Overlay already active for {self._target_id}") @@ -872,8 +871,8 @@ class WledTargetProcessor(TargetProcessor): _max_pixel_for_frame: object = None # Pre-allocate brightness scratch (uint16 intermediate + uint8 output) - _bright_u16: Optional[np.ndarray] = None - _bright_out: Optional[np.ndarray] = None + _bright_u16: np.ndarray | None = None + _bright_out: np.ndarray | None = None _bright_n = 0 def _cached_brightness(colors_in, brightness: int): @@ -917,7 +916,7 @@ class WledTargetProcessor(TargetProcessor): # pay for per-iteration probe-state checks. _device_url = self._device_config.device_url if self._device_config else "" _probe_enabled = _device_url.startswith("http") - _probe_task: Optional[asyncio.Task] = None + _probe_task: asyncio.Task | None = None if _probe_enabled: _probe_task = asyncio.create_task( self._run_liveness_probe_loop(_device_url), @@ -927,7 +926,7 @@ class WledTargetProcessor(TargetProcessor): self._device_reachable = None # --- CSPT (Color Strip Processing Template) filter cache --- - _cspt_cached_template_id: Optional[str] = None # last resolved template ID + _cspt_cached_template_id: str | None = None # last resolved template ID _cspt_filters: list = [] # list of PostprocessingFilter instances _cspt_check_interval = _CSPT_RECHECK_EVERY_N_ITERATIONS _cspt_check_counter = 0 diff --git a/server/src/ledgrab/core/processing/z2m_light_target_processor.py b/server/src/ledgrab/core/processing/z2m_light_target_processor.py index ce4a1b9..7f1dd05 100644 --- a/server/src/ledgrab/core/processing/z2m_light_target_processor.py +++ b/server/src/ledgrab/core/processing/z2m_light_target_processor.py @@ -10,7 +10,7 @@ Bypasses Home Assistant — bulbs must already be paired with the Z2M coordinato import asyncio import json import time -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Tuple import numpy as np @@ -36,14 +36,14 @@ class Z2MLightTargetProcessor(TargetProcessor): color_strip_source_id: str = "", color_value_source_id: str = "", brightness=None, - light_mappings: Optional[List[Z2MLightMapping]] = None, + light_mappings: List[Z2MLightMapping] | None = None, base_topic: str = DEFAULT_Z2M_BASE_TOPIC, update_rate: float = 5.0, transition=None, min_brightness_threshold: int = 0, color_tolerance: int = 5, stop_action: str = "none", - ctx: Optional[TargetContext] = None, + ctx: TargetContext | None = None, ): from ledgrab.storage.bindable import BindableFloat, bfloat @@ -76,14 +76,14 @@ class Z2MLightTargetProcessor(TargetProcessor): self._previous_on: Dict[str, bool] = {} self._latest_entity_colors: Dict[str, Tuple[int, int, int]] = {} self._ws_clients: List[Any] = [] - self._start_time: Optional[float] = None + self._start_time: float | None = None # MQTT runtime acquired from MQTTManager at start(); released at stop(). self._mqtt_runtime = None # Track whether we hold an outstanding acquire() so stop() knows to release. - self._mqtt_acquired_id: Optional[str] = None + self._mqtt_acquired_id: str | None = None @property - def device_id(self) -> Optional[str]: + def device_id(self) -> str | None: return None # Z2M targets don't use device providers # ─────────── Lifecycle ─────────── diff --git a/server/src/ledgrab/core/scenes/scene_activator.py b/server/src/ledgrab/core/scenes/scene_activator.py index cb12be3..e7eca89 100644 --- a/server/src/ledgrab/core/scenes/scene_activator.py +++ b/server/src/ledgrab/core/scenes/scene_activator.py @@ -3,7 +3,7 @@ These functions are used by both the scene-presets API route and the automation engine. """ -from typing import List, Optional, Set, Tuple +from typing import List, Set, Tuple from ledgrab.core.processing.processor_manager import ProcessorManager from ledgrab.storage.bindable import bfloat @@ -20,7 +20,7 @@ logger = get_logger(__name__) def capture_current_snapshot( target_store: OutputTargetStore, processor_manager: ProcessorManager, - target_ids: Optional[Set[str]] = None, + target_ids: Set[str] | None = None, ) -> List[TargetSnapshot]: """Capture current target state as a snapshot list. diff --git a/server/src/ledgrab/core/value_sources/game_event_value_source.py b/server/src/ledgrab/core/value_sources/game_event_value_source.py index 6aef789..d06844b 100644 --- a/server/src/ledgrab/core/value_sources/game_event_value_source.py +++ b/server/src/ledgrab/core/value_sources/game_event_value_source.py @@ -12,7 +12,7 @@ from __future__ import annotations import threading import time -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from ledgrab.core.processing.value_stream import ValueStream from ledgrab.utils import get_logger @@ -41,7 +41,7 @@ class GameEventValueStream(ValueStream): smoothing: float = 0.0, default_value: float = 0.5, timeout: float = 5.0, - event_bus: Optional["GameEventBus"] = None, + event_bus: "GameEventBus" | None = None, ) -> None: self._event_type = event_type self._min_game = min_game_value @@ -53,8 +53,8 @@ class GameEventValueStream(ValueStream): self._lock = threading.Lock() self._current_value: float = self._default_value - self._last_event_time: Optional[float] = None - self._subscription_id: Optional[str] = None + self._last_event_time: float | None = None + self._subscription_id: str | None = None self._has_received_event: bool = False def start(self) -> None: diff --git a/server/src/ledgrab/core/weather/weather_manager.py b/server/src/ledgrab/core/weather/weather_manager.py index 1519c88..53a4034 100644 --- a/server/src/ledgrab/core/weather/weather_manager.py +++ b/server/src/ledgrab/core/weather/weather_manager.py @@ -6,7 +6,7 @@ share one polling loop. Lazy-creates runtimes on first acquire(). import threading import time -from typing import Dict, Optional +from typing import Dict from ledgrab.core.weather.weather_provider import ( DEFAULT_WEATHER, @@ -34,7 +34,7 @@ class _WeatherRuntime: self._data: WeatherData = DEFAULT_WEATHER self._lock = threading.Lock() self._running = False - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None @property def data(self) -> WeatherData: diff --git a/server/src/ledgrab/paths.py b/server/src/ledgrab/paths.py index 8b64505..e39e45a 100644 --- a/server/src/ledgrab/paths.py +++ b/server/src/ledgrab/paths.py @@ -16,7 +16,6 @@ Precedence: import os from pathlib import Path - _ENV_DATA_DIR = "LEDGRAB_DATA_DIR" diff --git a/server/src/ledgrab/server_ref.py b/server/src/ledgrab/server_ref.py index dcd5b67..b51af47 100644 --- a/server/src/ledgrab/server_ref.py +++ b/server/src/ledgrab/server_ref.py @@ -5,14 +5,14 @@ Allows the shutdown API endpoint to trigger graceful shutdown via mechanism the system tray "Shutdown" menu item uses. """ -from typing import Any, Optional +from typing import Any from ledgrab.utils import get_logger logger = get_logger(__name__) -_server: Optional[Any] = None # uvicorn.Server -_tray: Optional[Any] = None # TrayManager +_server: Any | None = None # uvicorn.Server +_tray: Any | None = None # TrayManager def set_server(server: Any) -> None: diff --git a/server/src/ledgrab/storage/asset.py b/server/src/ledgrab/storage/asset.py index 197cf72..df69083 100644 --- a/server/src/ledgrab/storage/asset.py +++ b/server/src/ledgrab/storage/asset.py @@ -7,8 +7,7 @@ stored on the server. Assets are referenced by ID from other entities from dataclasses import dataclass, field from datetime import datetime -from typing import List, Optional - +from typing import List # Map MIME type prefixes to asset_type categories _MIME_TO_ASSET_TYPE = { @@ -39,7 +38,7 @@ class Asset: size_bytes: int created_at: datetime updated_at: datetime - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) prebuilt: bool = False # True for shipped assets deleted: bool = False # soft-delete for prebuilt assets diff --git a/server/src/ledgrab/storage/asset_store.py b/server/src/ledgrab/storage/asset_store.py index bee0bca..ff0be92 100644 --- a/server/src/ledgrab/storage/asset_store.py +++ b/server/src/ledgrab/storage/asset_store.py @@ -9,7 +9,7 @@ import shutil import uuid from datetime import datetime, timezone from pathlib import Path -from typing import List, Optional +from typing import List from ledgrab.storage.asset import Asset, asset_type_from_mime from ledgrab.storage.base_sqlite_store import BaseSqliteStore @@ -78,7 +78,7 @@ class AssetStore(BaseSqliteStore[Asset]): """Return visible assets filtered by type.""" return [a for a in self._items.values() if not a.deleted and a.asset_type == asset_type] - def get_file_path(self, asset_id: str) -> Optional[Path]: + def get_file_path(self, asset_id: str) -> Path | None: """Resolve the on-disk path for an asset's file. Returns None if missing.""" asset = self._items.get(asset_id) if asset is None or asset.deleted: @@ -91,12 +91,12 @@ class AssetStore(BaseSqliteStore[Asset]): name: str, filename: str, file_data: bytes, - mime_type: Optional[str] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, + mime_type: str | None = None, + description: str | None = None, + tags: List[str] | None = None, prebuilt: bool = False, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + icon: str | None = None, + icon_color: str | None = None, ) -> Asset: """Create a new asset from uploaded file data. @@ -172,11 +172,11 @@ class AssetStore(BaseSqliteStore[Asset]): def update_asset( self, asset_id: str, - name: Optional[str] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> Asset: """Update asset metadata (not the file itself).""" asset = self.get(asset_id) diff --git a/server/src/ledgrab/storage/audio_processing_template.py b/server/src/ledgrab/storage/audio_processing_template.py index acdb270..985a143 100644 --- a/server/src/ledgrab/storage/audio_processing_template.py +++ b/server/src/ledgrab/storage/audio_processing_template.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.core.filters.filter_instance import FilterInstance @@ -16,7 +16,7 @@ class AudioProcessingTemplate: filters: List[FilterInstance] created_at: datetime updated_at: datetime - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" diff --git a/server/src/ledgrab/storage/audio_processing_template_store.py b/server/src/ledgrab/storage/audio_processing_template_store.py index 0a6ad4f..f3fa134 100644 --- a/server/src/ledgrab/storage/audio_processing_template_store.py +++ b/server/src/ledgrab/storage/audio_processing_template_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.core.audio.filters.registry import AudioFilterRegistry from ledgrab.core.filters.filter_instance import FilterInstance @@ -35,11 +35,11 @@ class AudioProcessingTemplateStore(BaseSqliteStore[AudioProcessingTemplate]): def create_template( self, name: str, - filters: Optional[List[FilterInstance]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + filters: List[FilterInstance] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> AudioProcessingTemplate: self._check_name_unique(name) @@ -75,12 +75,12 @@ class AudioProcessingTemplateStore(BaseSqliteStore[AudioProcessingTemplate]): def update_template( self, template_id: str, - name: Optional[str] = None, - filters: Optional[List[FilterInstance]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + filters: List[FilterInstance] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> AudioProcessingTemplate: template = self.get(template_id) diff --git a/server/src/ledgrab/storage/audio_source.py b/server/src/ledgrab/storage/audio_source.py index 00f586e..b67fe00 100644 --- a/server/src/ledgrab/storage/audio_source.py +++ b/server/src/ledgrab/storage/audio_source.py @@ -7,7 +7,7 @@ An AudioSource represents a reusable audio input configuration: from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Dict, List, Optional, Type +from typing import Dict, List, Type @dataclass @@ -19,7 +19,7 @@ class AudioSource: source_type: str # "capture" | "processed" created_at: datetime updated_at: datetime - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" @@ -94,7 +94,7 @@ class CaptureAudioSource(AudioSource): device_index: int = -1 # -1 = default device is_loopback: bool = True # True = WASAPI loopback (system audio) - audio_template_id: Optional[str] = None # references AudioCaptureTemplate + audio_template_id: str | None = None # references AudioCaptureTemplate def to_dict(self) -> dict: d = super().to_dict() diff --git a/server/src/ledgrab/storage/audio_source_store.py b/server/src/ledgrab/storage/audio_source_store.py index 94d47d5..2b9f0c4 100644 --- a/server/src/ledgrab/storage/audio_source_store.py +++ b/server/src/ledgrab/storage/audio_source_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import List, NamedTuple, Optional, Set +from typing import List, NamedTuple, Set from ledgrab.storage.audio_source import ( AudioSource, @@ -26,7 +26,7 @@ class ResolvedAudioSource(NamedTuple): device_index: int is_loopback: bool - audio_template_id: Optional[str] + audio_template_id: str | None audio_processing_template_ids: List[str] # ordered list of template IDs along the chain @@ -47,15 +47,15 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]): self, name: str, source_type: str, - device_index: Optional[int] = None, - is_loopback: Optional[bool] = None, - audio_source_id: Optional[str] = None, - description: Optional[str] = None, - audio_template_id: Optional[str] = None, - tags: Optional[List[str]] = None, - audio_processing_template_id: Optional[str] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + device_index: int | None = None, + is_loopback: bool | None = None, + audio_source_id: str | None = None, + description: str | None = None, + audio_template_id: str | None = None, + tags: List[str] | None = None, + audio_processing_template_id: str | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> AudioSource: self._check_name_unique(name) @@ -115,16 +115,16 @@ class AudioSourceStore(BaseSqliteStore[AudioSource]): def update_source( self, source_id: str, - name: Optional[str] = None, - device_index: Optional[int] = None, - is_loopback: Optional[bool] = None, - audio_source_id: Optional[str] = None, - description: Optional[str] = None, - audio_template_id: Optional[str] = None, - tags: Optional[List[str]] = None, - audio_processing_template_id: Optional[str] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + device_index: int | None = None, + is_loopback: bool | None = None, + audio_source_id: str | None = None, + description: str | None = None, + audio_template_id: str | None = None, + tags: List[str] | None = None, + audio_processing_template_id: str | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> AudioSource: source = self.get(source_id) diff --git a/server/src/ledgrab/storage/audio_template.py b/server/src/ledgrab/storage/audio_template.py index 76ccca4..7f6c7eb 100644 --- a/server/src/ledgrab/storage/audio_template.py +++ b/server/src/ledgrab/storage/audio_template.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List @dataclass @@ -15,7 +15,7 @@ class AudioCaptureTemplate: engine_config: Dict[str, Any] created_at: datetime updated_at: datetime - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" diff --git a/server/src/ledgrab/storage/audio_template_store.py b/server/src/ledgrab/storage/audio_template_store.py index a547bc2..021fd62 100644 --- a/server/src/ledgrab/storage/audio_template_store.py +++ b/server/src/ledgrab/storage/audio_template_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List from ledgrab.core.audio.factory import AudioEngineRegistry from ledgrab.storage.audio_template import AudioCaptureTemplate @@ -64,7 +64,7 @@ class AudioTemplateStore(BaseSqliteStore[AudioCaptureTemplate]): f"({template_id}, engine={best_engine})" ) - def get_default_template_id(self) -> Optional[str]: + def get_default_template_id(self) -> str | None: """Return the ID of the first template, or None if none exist.""" if self._items: return next(iter(self._items)) @@ -75,10 +75,10 @@ class AudioTemplateStore(BaseSqliteStore[AudioCaptureTemplate]): name: str, engine_type: str, engine_config: Dict[str, Any], - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> AudioCaptureTemplate: self._check_name_unique(name) @@ -105,13 +105,13 @@ class AudioTemplateStore(BaseSqliteStore[AudioCaptureTemplate]): def update_template( self, template_id: str, - name: Optional[str] = None, - engine_type: Optional[str] = None, - engine_config: Optional[Dict[str, Any]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + engine_type: str | None = None, + engine_config: Dict[str, Any] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> AudioCaptureTemplate: template = self.get(template_id) diff --git a/server/src/ledgrab/storage/automation.py b/server/src/ledgrab/storage/automation.py index 2f0fbf1..6518ac7 100644 --- a/server/src/ledgrab/storage/automation.py +++ b/server/src/ledgrab/storage/automation.py @@ -3,7 +3,7 @@ import logging from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Dict, List, Optional, Type +from typing import Dict, List, Type logger = logging.getLogger(__name__) @@ -281,9 +281,9 @@ class Automation: enabled: bool rule_logic: str # "or" | "and" rules: List[Rule] - scene_preset_id: Optional[str] # scene to activate when rules are met + scene_preset_id: str | None # scene to activate when rules are met deactivation_mode: str # "none" | "revert" | "fallback_scene" - deactivation_scene_preset_id: Optional[str] # scene for fallback_scene mode + deactivation_scene_preset_id: str | None # scene for fallback_scene mode created_at: datetime updated_at: datetime tags: List[str] = field(default_factory=list) diff --git a/server/src/ledgrab/storage/automation_store.py b/server/src/ledgrab/storage/automation_store.py index b146207..9564d88 100644 --- a/server/src/ledgrab/storage/automation_store.py +++ b/server/src/ledgrab/storage/automation_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.storage.automation import Automation, Rule from ledgrab.storage.base_sqlite_store import BaseSqliteStore @@ -29,16 +29,16 @@ class AutomationStore(BaseSqliteStore[Automation]): name: str, enabled: bool = True, rule_logic: str = "or", - rules: Optional[List[Rule]] = None, - scene_preset_id: Optional[str] = None, + rules: List[Rule] | None = None, + scene_preset_id: str | None = None, deactivation_mode: str = "none", - deactivation_scene_preset_id: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + deactivation_scene_preset_id: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, # Legacy parameter aliases - condition_logic: Optional[str] = None, - conditions: Optional[List[Rule]] = None, + condition_logic: str | None = None, + conditions: List[Rule] | None = None, ) -> Automation: # Support legacy parameter names if condition_logic is not None and rule_logic == "or": @@ -77,19 +77,19 @@ class AutomationStore(BaseSqliteStore[Automation]): def update_automation( self, automation_id: str, - name: Optional[str] = None, - enabled: Optional[bool] = None, - rule_logic: Optional[str] = None, - rules: Optional[List[Rule]] = None, + name: str | None = None, + enabled: bool | None = None, + rule_logic: str | None = None, + rules: List[Rule] | None = None, scene_preset_id: str = "__unset__", - deactivation_mode: Optional[str] = None, + deactivation_mode: str | None = None, deactivation_scene_preset_id: str = "__unset__", - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, # Legacy parameter aliases - condition_logic: Optional[str] = None, - conditions: Optional[List[Rule]] = None, + condition_logic: str | None = None, + conditions: List[Rule] | None = None, ) -> Automation: # Support legacy parameter names if condition_logic is not None and rule_logic is None: diff --git a/server/src/ledgrab/storage/color_strip_processing_template.py b/server/src/ledgrab/storage/color_strip_processing_template.py index 745ebc7..91c84ee 100644 --- a/server/src/ledgrab/storage/color_strip_processing_template.py +++ b/server/src/ledgrab/storage/color_strip_processing_template.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.core.filters.filter_instance import FilterInstance @@ -18,7 +18,7 @@ class ColorStripProcessingTemplate: filters: List[FilterInstance] created_at: datetime updated_at: datetime - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" diff --git a/server/src/ledgrab/storage/color_strip_processing_template_store.py b/server/src/ledgrab/storage/color_strip_processing_template_store.py index b77f341..b635f99 100644 --- a/server/src/ledgrab/storage/color_strip_processing_template_store.py +++ b/server/src/ledgrab/storage/color_strip_processing_template_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.core.filters.filter_instance import FilterInstance from ledgrab.core.filters.registry import FilterRegistry @@ -72,11 +72,11 @@ class ColorStripProcessingTemplateStore(BaseSqliteStore[ColorStripProcessingTemp def create_template( self, name: str, - filters: Optional[List[FilterInstance]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + filters: List[FilterInstance] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> ColorStripProcessingTemplate: self._check_name_unique(name) @@ -109,12 +109,12 @@ class ColorStripProcessingTemplateStore(BaseSqliteStore[ColorStripProcessingTemp def update_template( self, template_id: str, - name: Optional[str] = None, - filters: Optional[List[FilterInstance]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + filters: List[FilterInstance] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> ColorStripProcessingTemplate: template = self.get(template_id) diff --git a/server/src/ledgrab/storage/color_strip_source.py b/server/src/ledgrab/storage/color_strip_source.py index 10cf4bc..b9cf14b 100644 --- a/server/src/ledgrab/storage/color_strip_source.py +++ b/server/src/ledgrab/storage/color_strip_source.py @@ -18,7 +18,7 @@ Current types: from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Dict, List, Optional, Type +from typing import Dict, List, Type from ledgrab.core.capture.calibration import ( CalibrationConfig, @@ -60,8 +60,8 @@ class ColorStripSource: source_type: str # "picture" | future types created_at: datetime updated_at: datetime - description: Optional[str] = None - clock_id: Optional[str] = None # optional SyncClock reference + description: str | None = None + clock_id: str | None = None # optional SyncClock reference tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" @@ -102,9 +102,9 @@ class ColorStripSource: source_type: str, created_at: datetime, updated_at: datetime, - description: Optional[str] = None, - clock_id: Optional[str] = None, - tags: Optional[List[str]] = None, + description: str | None = None, + clock_id: str | None = None, + tags: List[str] | None = None, **kwargs, ) -> "ColorStripSource": """Create an instance from keyword arguments. @@ -375,7 +375,7 @@ class SingleColorStripSource(ColorStripSource): """ color: BindableColor = field(default_factory=lambda: BindableColor([255, 255, 255])) - animation: Optional[dict] = None # {"enabled": bool, "type": str, "speed": float} or None + animation: dict | None = None # {"enabled": bool, "type": str, "speed": float} or None def to_dict(self) -> dict: d = super().to_dict() @@ -448,9 +448,9 @@ class GradientColorStripSource(ColorStripSource): {"position": 1.0, "color": [0, 0, 255]}, ] ) - animation: Optional[dict] = None # {"enabled": bool, "type": str, "speed": float} or None + animation: dict | None = None # {"enabled": bool, "type": str, "speed": float} or None easing: str = "linear" # linear | ease_in_out | step | cubic - gradient_id: Optional[str] = None # references a Gradient entity; overrides inline stops + gradient_id: str | None = None # references a Gradient entity; overrides inline stops def to_dict(self) -> dict: d = super().to_dict() @@ -536,14 +536,14 @@ class EffectColorStripSource(ColorStripSource): effect_type: str = "fire" # fire | meteor | plasma | noise | aurora + new types palette: str = "fire" # legacy palette name (kept for migration) - gradient_id: Optional[str] = None # references a Gradient entity (preferred over palette) + gradient_id: str | None = None # references a Gradient entity (preferred over palette) color: BindableColor = field( default_factory=lambda: BindableColor([255, 80, 0]) ) # [R,G,B] for meteor/comet/bouncing_ball head intensity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0)) scale: BindableFloat = field(default_factory=lambda: BindableFloat(1.0)) mirror: bool = False # bounce mode (meteor/comet) - custom_palette: Optional[list] = None # legacy [[pos, R, G, B], ...] custom palette stops + custom_palette: list | None = None # legacy [[pos, R, G, B], ...] custom palette stops def to_dict(self) -> dict: d = super().to_dict() @@ -650,7 +650,7 @@ class AudioColorStripSource(ColorStripSource): sensitivity: BindableFloat = field(default_factory=lambda: BindableFloat(1.0)) smoothing: BindableFloat = field(default_factory=lambda: BindableFloat(0.3)) palette: str = "rainbow" # legacy palette name (kept for migration) - gradient_id: Optional[str] = None # references a Gradient entity (preferred) + gradient_id: str | None = None # references a Gradient entity (preferred) color: BindableColor = field(default_factory=lambda: BindableColor([0, 255, 0])) color_peak: BindableColor = field(default_factory=lambda: BindableColor([255, 0, 0])) led_count: int = 0 # 0 = use device LED count @@ -999,7 +999,7 @@ class NotificationColorStripSource(ColorStripSource): app_filter_mode: str = "off" # off | whitelist | blacklist app_filter_list: list = field(default_factory=list) # app names for filter os_listener: bool = False # whether to listen for OS notifications - sound_asset_id: Optional[str] = None # global notification sound (asset ID) + sound_asset_id: str | None = None # global notification sound (asset ID) sound_volume: BindableFloat = field(default_factory=lambda: BindableFloat(1.0)) app_sounds: dict = field( default_factory=dict @@ -1707,7 +1707,7 @@ class MathWaveColorStripSource(ColorStripSource): ] ) speed: BindableFloat = field(default_factory=lambda: BindableFloat(1.0)) - gradient_id: Optional[str] = None + gradient_id: str | None = None def to_dict(self) -> dict: d = super().to_dict() diff --git a/server/src/ledgrab/storage/device_store.py b/server/src/ledgrab/storage/device_store.py index 69211ea..0482391 100644 --- a/server/src/ledgrab/storage/device_store.py +++ b/server/src/ledgrab/storage/device_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, List from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.database import Database @@ -59,13 +59,13 @@ class Device: led_count: int, enabled: bool = True, device_type: str = "wled", - baud_rate: Optional[int] = None, + baud_rate: int | None = None, software_brightness: int = 255, auto_shutdown: bool = False, send_latency_ms: int = 0, rgbw: bool = False, zone_mode: str = "combined", - tags: Optional[List[str]] = None, + tags: List[str] | None = None, # DMX (Art-Net / sACN) fields dmx_protocol: str = "artnet", dmx_start_universe: int = 0, @@ -109,13 +109,13 @@ class Device: # Default color strip processing template default_css_processing_template_id: str = "", # Group device fields - group_device_ids: Optional[List[str]] = None, + group_device_ids: List[str] | None = None, group_mode: str = "sequence", # Custom card icon (frontend display only) icon: str = "", icon_color: str = "", - created_at: Optional[datetime] = None, - updated_at: Optional[datetime] = None, + created_at: datetime | None = None, + updated_at: datetime | None = None, ): self.id = device_id self.name = name @@ -563,12 +563,12 @@ class DeviceStore(BaseSqliteStore[Device]): url: str, led_count: int, device_type: str = "wled", - baud_rate: Optional[int] = None, + baud_rate: int | None = None, auto_shutdown: bool = False, send_latency_ms: int = 0, rgbw: bool = False, zone_mode: str = "combined", - tags: Optional[List[str]] = None, + tags: List[str] | None = None, dmx_protocol: str = "artnet", dmx_start_universe: int = 0, dmx_start_channel: int = 1, @@ -594,7 +594,7 @@ class DeviceStore(BaseSqliteStore[Device]): ble_family: str = "", ble_govee_key: str = "", mqtt_source_id: str = "", - group_device_ids: Optional[List[str]] = None, + group_device_ids: List[str] | None = None, group_mode: str = "sequence", ) -> Device: """Create a new device.""" @@ -708,7 +708,7 @@ class DeviceStore(BaseSqliteStore[Device]): def validate_group_no_cycles( self, - device_id: Optional[str], + device_id: str | None, group_device_ids: List[str], ) -> None: """Raise ValueError if adding these children would create a cycle. @@ -737,7 +737,7 @@ class DeviceStore(BaseSqliteStore[Device]): def resolve_group_led_count( self, device_ids: List[str], - _seen: Optional[set] = None, + _seen: set | None = None, ) -> int: """Sum led_counts of devices, recursively resolving nested sequence groups.""" if _seen is None: diff --git a/server/src/ledgrab/storage/game_integration.py b/server/src/ledgrab/storage/game_integration.py index e53d7ae..080b4d9 100644 --- a/server/src/ledgrab/storage/game_integration.py +++ b/server/src/ledgrab/storage/game_integration.py @@ -8,7 +8,7 @@ per-integration config). import uuid from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Any, List, Optional +from typing import Any, List @dataclass @@ -78,7 +78,7 @@ class GameIntegrationConfig: event_mappings: List[EventMapping] created_at: datetime updated_at: datetime - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" @@ -133,12 +133,12 @@ class GameIntegrationConfig: name: str, adapter_type: str, enabled: bool = True, - adapter_config: Optional[dict[str, Any]] = None, - event_mappings: Optional[List[EventMapping]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + adapter_config: dict[str, Any] | None = None, + event_mappings: List[EventMapping] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> "GameIntegrationConfig": """Factory method to create a new config with generated ID and timestamps.""" now = datetime.now(timezone.utc) @@ -159,15 +159,15 @@ class GameIntegrationConfig: def apply_update( self, - name: Optional[str] = None, - adapter_type: Optional[str] = None, - enabled: Optional[bool] = None, - adapter_config: Optional[dict[str, Any]] = None, - event_mappings: Optional[List[EventMapping]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + adapter_type: str | None = None, + enabled: bool | None = None, + adapter_config: dict[str, Any] | None = None, + event_mappings: List[EventMapping] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> "GameIntegrationConfig": """Return a new config with updated fields (immutable update).""" return GameIntegrationConfig( diff --git a/server/src/ledgrab/storage/game_integration_store.py b/server/src/ledgrab/storage/game_integration_store.py index ebb14a8..67a132b 100644 --- a/server/src/ledgrab/storage/game_integration_store.py +++ b/server/src/ledgrab/storage/game_integration_store.py @@ -4,7 +4,7 @@ Provides CRUD operations for GameIntegrationConfig entities with name uniqueness validation and write-through caching. """ -from typing import Any, List, Optional +from typing import Any, List from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.database import Database @@ -37,12 +37,12 @@ class GameIntegrationStore(BaseSqliteStore[GameIntegrationConfig]): name: str, adapter_type: str, enabled: bool = True, - adapter_config: Optional[dict[str, Any]] = None, - event_mappings: Optional[List[EventMapping]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + adapter_config: dict[str, Any] | None = None, + event_mappings: List[EventMapping] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> GameIntegrationConfig: """Create a new game integration config. @@ -85,15 +85,15 @@ class GameIntegrationStore(BaseSqliteStore[GameIntegrationConfig]): def update_integration( self, integration_id: str, - name: Optional[str] = None, - adapter_type: Optional[str] = None, - enabled: Optional[bool] = None, - adapter_config: Optional[dict[str, Any]] = None, - event_mappings: Optional[List[EventMapping]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + adapter_type: str | None = None, + enabled: bool | None = None, + adapter_config: dict[str, Any] | None = None, + event_mappings: List[EventMapping] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> GameIntegrationConfig: """Update an existing game integration config. diff --git a/server/src/ledgrab/storage/gradient.py b/server/src/ledgrab/storage/gradient.py index cad5163..def7b9e 100644 --- a/server/src/ledgrab/storage/gradient.py +++ b/server/src/ledgrab/storage/gradient.py @@ -7,7 +7,7 @@ strip sources. Eight built-in gradients are seeded on first run. from dataclasses import dataclass, field from datetime import datetime -from typing import List, Optional +from typing import List @dataclass @@ -20,7 +20,7 @@ class Gradient: is_builtin: bool created_at: datetime updated_at: datetime - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" @@ -67,8 +67,8 @@ class Gradient: is_builtin: bool = False, created_at: datetime, updated_at: datetime, - description: Optional[str] = None, - tags: Optional[List[str]] = None, + description: str | None = None, + tags: List[str] | None = None, ) -> "Gradient": return cls( id=id, diff --git a/server/src/ledgrab/storage/gradient_store.py b/server/src/ledgrab/storage/gradient_store.py index b558a5d..4d21ae2 100644 --- a/server/src/ledgrab/storage/gradient_store.py +++ b/server/src/ledgrab/storage/gradient_store.py @@ -7,7 +7,7 @@ deleted or modified. import uuid from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.database import Database @@ -116,7 +116,7 @@ class GradientStore(BaseSqliteStore[Gradient]): def get_gradient(self, gradient_id: str) -> Gradient: return self.get(gradient_id) - def get_by_name(self, name: str) -> Optional[Gradient]: + def get_by_name(self, name: str) -> Gradient | None: """Look up a gradient by name (case-insensitive).""" lower = name.lower() for g in self._items.values(): @@ -128,10 +128,10 @@ class GradientStore(BaseSqliteStore[Gradient]): self, name: str, stops: list, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> Gradient: self._check_name_unique(name) gid = f"gr_{uuid.uuid4().hex[:8]}" @@ -156,12 +156,12 @@ class GradientStore(BaseSqliteStore[Gradient]): def update_gradient( self, gradient_id: str, - name: Optional[str] = None, - stops: Optional[list] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + stops: list | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> Gradient: gradient = self.get(gradient_id) if gradient.is_builtin: @@ -190,14 +190,14 @@ class GradientStore(BaseSqliteStore[Gradient]): raise ValueError("Built-in gradients cannot be deleted") self.delete(gradient_id) - def get_stops_by_id(self, gradient_id: str) -> Optional[list]: + def get_stops_by_id(self, gradient_id: str) -> list | None: """Return stops list for a gradient ID, or None if not found.""" gradient = self._items.get(gradient_id) if gradient is None: return None return gradient.stops - def resolve_stops(self, gradient_id: Optional[str]) -> Optional[list]: + def resolve_stops(self, gradient_id: str | None) -> list | None: """Resolve a gradient_id to its stops list, or None if invalid/missing.""" if not gradient_id: return None diff --git a/server/src/ledgrab/storage/ha_light_output_target.py b/server/src/ledgrab/storage/ha_light_output_target.py index df34d4d..deb802b 100644 --- a/server/src/ledgrab/storage/ha_light_output_target.py +++ b/server/src/ledgrab/storage/ha_light_output_target.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.storage.bindable import BindableFloat from ledgrab.storage.output_target import OutputTarget @@ -136,9 +136,9 @@ class HALightOutputTarget(OutputTarget, type_key="ha_light"): color_tolerance=None, stop_action=None, description=None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, **_kwargs, ) -> None: """Apply mutable field updates.""" diff --git a/server/src/ledgrab/storage/home_assistant_source.py b/server/src/ledgrab/storage/home_assistant_source.py index 6dac4ed..4f8e9c0 100644 --- a/server/src/ledgrab/storage/home_assistant_source.py +++ b/server/src/ledgrab/storage/home_assistant_source.py @@ -7,7 +7,7 @@ Referenced by HomeAssistantColorStripSource and HomeAssistantCondition. from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.utils import secret_box @@ -48,7 +48,7 @@ class HomeAssistantSource: entity_filters: List[str] = field( default_factory=list ) # optional allowlist (e.g. ["sensor.*"]) - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" diff --git a/server/src/ledgrab/storage/home_assistant_store.py b/server/src/ledgrab/storage/home_assistant_store.py index 23219ec..bca327f 100644 --- a/server/src/ledgrab/storage/home_assistant_store.py +++ b/server/src/ledgrab/storage/home_assistant_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.database import Database @@ -70,11 +70,11 @@ class HomeAssistantStore(BaseSqliteStore[HomeAssistantSource]): host: str, token: str, use_ssl: bool = False, - entity_filters: Optional[List[str]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + entity_filters: List[str] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> HomeAssistantSource: if not host: raise ValueError("host is required") @@ -109,15 +109,15 @@ class HomeAssistantStore(BaseSqliteStore[HomeAssistantSource]): def update_source( self, source_id: str, - name: Optional[str] = None, - host: Optional[str] = None, - token: Optional[str] = None, - use_ssl: Optional[bool] = None, - entity_filters: Optional[List[str]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + host: str | None = None, + token: str | None = None, + use_ssl: bool | None = None, + entity_filters: List[str] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> HomeAssistantSource: existing = self.get(source_id) diff --git a/server/src/ledgrab/storage/http_endpoint.py b/server/src/ledgrab/storage/http_endpoint.py index 797e6fa..c407510 100644 --- a/server/src/ledgrab/storage/http_endpoint.py +++ b/server/src/ledgrab/storage/http_endpoint.py @@ -12,7 +12,7 @@ storage/home_assistant_source.py). from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Dict, List, Optional +from typing import Dict, List from ledgrab.utils import secret_box @@ -52,7 +52,7 @@ class HTTPEndpoint: auth_token: str = "" # convenience: becomes Authorization: Bearer headers: Dict[str, str] = field(default_factory=dict) timeout_s: float = 10.0 - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" diff --git a/server/src/ledgrab/storage/http_endpoint_store.py b/server/src/ledgrab/storage/http_endpoint_store.py index 8611ac9..908faed 100644 --- a/server/src/ledgrab/storage/http_endpoint_store.py +++ b/server/src/ledgrab/storage/http_endpoint_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import Dict, List, Optional +from typing import Dict, List from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.database import Database @@ -62,12 +62,12 @@ class HTTPEndpointStore(BaseSqliteStore[HTTPEndpoint]): url: str, method: str = "GET", auth_token: str = "", - headers: Optional[Dict[str, str]] = None, + headers: Dict[str, str] | None = None, timeout_s: float = 10.0, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> HTTPEndpoint: if not url: raise ValueError("url is required") @@ -105,16 +105,16 @@ class HTTPEndpointStore(BaseSqliteStore[HTTPEndpoint]): def update_endpoint( self, endpoint_id: str, - name: Optional[str] = None, - url: Optional[str] = None, - method: Optional[str] = None, - auth_token: Optional[str] = None, - headers: Optional[Dict[str, str]] = None, - timeout_s: Optional[float] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + url: str | None = None, + method: str | None = None, + auth_token: str | None = None, + headers: Dict[str, str] | None = None, + timeout_s: float | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> HTTPEndpoint: existing = self.get(endpoint_id) diff --git a/server/src/ledgrab/storage/mqtt_source.py b/server/src/ledgrab/storage/mqtt_source.py index ddfbec7..5b8b3a8 100644 --- a/server/src/ledgrab/storage/mqtt_source.py +++ b/server/src/ledgrab/storage/mqtt_source.py @@ -7,7 +7,7 @@ and state publishing. from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.utils import secret_box @@ -48,7 +48,7 @@ class MQTTSource: password: str = "" client_id: str = "ledgrab" base_topic: str = "ledgrab" - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" diff --git a/server/src/ledgrab/storage/mqtt_source_store.py b/server/src/ledgrab/storage/mqtt_source_store.py index 2f6b3a3..43ef769 100644 --- a/server/src/ledgrab/storage/mqtt_source_store.py +++ b/server/src/ledgrab/storage/mqtt_source_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.database import Database @@ -70,10 +70,10 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]): password: str = "", client_id: str = "ledgrab", base_topic: str = "ledgrab", - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> MQTTSource: if not broker_host: raise ValueError("broker_host is required") @@ -108,17 +108,17 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]): def update_source( self, source_id: str, - name: Optional[str] = None, - broker_host: Optional[str] = None, - broker_port: Optional[int] = None, - username: Optional[str] = None, - password: Optional[str] = None, - client_id: Optional[str] = None, - base_topic: Optional[str] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + broker_host: str | None = None, + broker_port: int | None = None, + username: str | None = None, + password: str | None = None, + client_id: str | None = None, + base_topic: str | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> MQTTSource: existing = self.get(source_id) diff --git a/server/src/ledgrab/storage/output_target.py b/server/src/ledgrab/storage/output_target.py index babb422..42d4a6e 100644 --- a/server/src/ledgrab/storage/output_target.py +++ b/server/src/ledgrab/storage/output_target.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from datetime import datetime -from typing import ClassVar, Dict, List, Optional, Type +from typing import ClassVar, Dict, List, Type @dataclass @@ -29,7 +29,7 @@ class OutputTarget: target_type: str # "led", "ha_light", "z2m_light" created_at: datetime updated_at: datetime - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) # Custom card icon (frontend display only). When empty, the LED target # card inherits the icon from its referenced device; HA-light targets @@ -59,9 +59,9 @@ class OutputTarget: name=None, device_id=None, description=None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, **_kwargs, ) -> None: """Apply mutable field updates. Base handles common fields; subclasses handle type-specific ones.""" diff --git a/server/src/ledgrab/storage/output_target_store.py b/server/src/ledgrab/storage/output_target_store.py index 9c070b0..d4bd11b 100644 --- a/server/src/ledgrab/storage/output_target_store.py +++ b/server/src/ledgrab/storage/output_target_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import Any, List, Optional +from typing import Any, List from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.bindable import BindableFloat @@ -72,7 +72,7 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): return BindableFloat(default) def _finalize( - self, target: OutputTarget, *, tags: Optional[List[str]], log_type: str + self, target: OutputTarget, *, tags: List[str] | None, log_type: str ) -> OutputTarget: target.tags = tags or [] self._items[target.id] = target @@ -95,8 +95,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): min_brightness_threshold: Any = 0, adaptive_fps: bool = False, protocol: str = "ddp", - description: Optional[str] = None, - tags: Optional[List[str]] = None, + description: str | None = None, + tags: List[str] | None = None, # legacy compat brightness_value_source_id: str = "", ) -> WledOutputTarget: @@ -131,14 +131,14 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): color_strip_source_id: str = "", color_value_source_id: str = "", brightness: Any = None, - ha_light_mappings: Optional[List[HALightMapping]] = None, + ha_light_mappings: List[HALightMapping] | None = None, update_rate: Any = 2.0, transition: Any = None, min_brightness_threshold: Any = 0, color_tolerance: Any = 5, stop_action: str = "none", - description: Optional[str] = None, - tags: Optional[List[str]] = None, + description: str | None = None, + tags: List[str] | None = None, # legacy compat brightness_value_source_id: str = "", ) -> HALightOutputTarget: @@ -175,15 +175,15 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): color_strip_source_id: str = "", color_value_source_id: str = "", brightness: Any = None, - z2m_light_mappings: Optional[List[Z2MLightMapping]] = None, + z2m_light_mappings: List[Z2MLightMapping] | None = None, base_topic: str = DEFAULT_Z2M_BASE_TOPIC, update_rate: Any = 5.0, transition: Any = None, min_brightness_threshold: Any = 0, color_tolerance: Any = 5, stop_action: str = "none", - description: Optional[str] = None, - tags: Optional[List[str]] = None, + description: str | None = None, + tags: List[str] | None = None, # legacy compat brightness_value_source_id: str = "", ) -> Z2MLightOutputTarget: @@ -227,17 +227,17 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): min_brightness_threshold: int = 0, adaptive_fps: bool = False, protocol: str = "ddp", - description: Optional[str] = None, - tags: Optional[List[str]] = None, + description: str | None = None, + tags: List[str] | None = None, ha_source_id: str = "", source_kind: str = "css", color_value_source_id: str = "", - ha_light_mappings: Optional[List[HALightMapping]] = None, - update_rate: Optional[float] = None, + ha_light_mappings: List[HALightMapping] | None = None, + update_rate: float | None = None, transition=None, color_tolerance: int = 5, stop_action: str = "none", - z2m_light_mappings: Optional[List[Z2MLightMapping]] = None, + z2m_light_mappings: List[Z2MLightMapping] | None = None, base_topic: str = DEFAULT_Z2M_BASE_TOPIC, mqtt_source_id: str = "", brightness_value_source_id: str = "", @@ -305,7 +305,7 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): # ---- typed update methods ------------------------------------------ - def _begin_update(self, target_id: str, new_name: Optional[str]) -> OutputTarget: + def _begin_update(self, target_id: str, new_name: str | None) -> OutputTarget: if target_id not in self._items: raise ValueError(f"Output target not found: {target_id}") target = self._items[target_id] @@ -325,21 +325,21 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): self, target_id: str, *, - name: Optional[str] = None, - device_id: Optional[str] = None, - color_strip_source_id: Optional[str] = None, + name: str | None = None, + device_id: str | None = None, + color_strip_source_id: str | None = None, brightness: Any = None, fps: Any = None, - keepalive_interval: Optional[float] = None, - state_check_interval: Optional[int] = None, + keepalive_interval: float | None = None, + state_check_interval: int | None = None, min_brightness_threshold: Any = None, - adaptive_fps: Optional[bool] = None, - protocol: Optional[str] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, - brightness_value_source_id: Optional[str] = None, + adaptive_fps: bool | None = None, + protocol: str | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, + brightness_value_source_id: str | None = None, ) -> WledOutputTarget: target = self._begin_update(target_id, name) if not isinstance(target, WledOutputTarget): @@ -367,23 +367,23 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): self, target_id: str, *, - name: Optional[str] = None, - ha_source_id: Optional[str] = None, - source_kind: Optional[str] = None, - color_strip_source_id: Optional[str] = None, - color_value_source_id: Optional[str] = None, + name: str | None = None, + ha_source_id: str | None = None, + source_kind: str | None = None, + color_strip_source_id: str | None = None, + color_value_source_id: str | None = None, brightness: Any = None, - ha_light_mappings: Optional[List[HALightMapping]] = None, + ha_light_mappings: List[HALightMapping] | None = None, update_rate: Any = None, transition: Any = None, min_brightness_threshold: Any = None, color_tolerance: Any = None, - stop_action: Optional[str] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, - brightness_value_source_id: Optional[str] = None, + stop_action: str | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, + brightness_value_source_id: str | None = None, ) -> HALightOutputTarget: target = self._begin_update(target_id, name) if not isinstance(target, HALightOutputTarget): @@ -413,24 +413,24 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): self, target_id: str, *, - name: Optional[str] = None, - mqtt_source_id: Optional[str] = None, - source_kind: Optional[str] = None, - color_strip_source_id: Optional[str] = None, - color_value_source_id: Optional[str] = None, + name: str | None = None, + mqtt_source_id: str | None = None, + source_kind: str | None = None, + color_strip_source_id: str | None = None, + color_value_source_id: str | None = None, brightness: Any = None, - z2m_light_mappings: Optional[List[Z2MLightMapping]] = None, - base_topic: Optional[str] = None, + z2m_light_mappings: List[Z2MLightMapping] | None = None, + base_topic: str | None = None, update_rate: Any = None, transition: Any = None, min_brightness_threshold: Any = None, color_tolerance: Any = None, - stop_action: Optional[str] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, - brightness_value_source_id: Optional[str] = None, + stop_action: str | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, + brightness_value_source_id: str | None = None, ) -> Z2MLightOutputTarget: target = self._begin_update(target_id, name) if not isinstance(target, Z2MLightOutputTarget): diff --git a/server/src/ledgrab/storage/pattern_template.py b/server/src/ledgrab/storage/pattern_template.py index 066e6b4..2cfd62c 100644 --- a/server/src/ledgrab/storage/pattern_template.py +++ b/server/src/ledgrab/storage/pattern_template.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import List, Optional +from typing import List @dataclass @@ -44,7 +44,7 @@ class PatternTemplate: rectangles: List[KeyColorRectangle] created_at: datetime updated_at: datetime - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" diff --git a/server/src/ledgrab/storage/pattern_template_store.py b/server/src/ledgrab/storage/pattern_template_store.py index bf19de9..dff4d57 100644 --- a/server/src/ledgrab/storage/pattern_template_store.py +++ b/server/src/ledgrab/storage/pattern_template_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.database import Database @@ -57,11 +57,11 @@ class PatternTemplateStore(BaseSqliteStore[PatternTemplate]): def create_template( self, name: str, - rectangles: Optional[List[KeyColorRectangle]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + rectangles: List[KeyColorRectangle] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> PatternTemplate: self._check_name_unique(name) @@ -92,12 +92,12 @@ class PatternTemplateStore(BaseSqliteStore[PatternTemplate]): def update_template( self, template_id: str, - name: Optional[str] = None, - rectangles: Optional[List[KeyColorRectangle]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + rectangles: List[KeyColorRectangle] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> PatternTemplate: template = self.get(template_id) diff --git a/server/src/ledgrab/storage/picture_source.py b/server/src/ledgrab/storage/picture_source.py index 9dce689..7d757aa 100644 --- a/server/src/ledgrab/storage/picture_source.py +++ b/server/src/ledgrab/storage/picture_source.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Dict, List, Optional, Type +from typing import Dict, List, Type @dataclass @@ -21,7 +21,7 @@ class PictureSource: stream_type: str # "raw", "processed", "static_image", or "video" created_at: datetime updated_at: datetime - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" @@ -147,7 +147,7 @@ class ProcessedPictureSource(PictureSource): class StaticImagePictureSource(PictureSource): """A static image stream from an uploaded asset.""" - image_asset_id: Optional[str] = None + image_asset_id: str | None = None def to_dict(self) -> dict: d = super().to_dict() @@ -168,13 +168,13 @@ class StaticImagePictureSource(PictureSource): class VideoCaptureSource(PictureSource): """A video stream from an uploaded video asset.""" - video_asset_id: Optional[str] = None + video_asset_id: str | None = None loop: bool = True playback_speed: float = 1.0 - start_time: Optional[float] = None - end_time: Optional[float] = None - resolution_limit: Optional[int] = None - clock_id: Optional[str] = None + start_time: float | None = None + end_time: float | None = None + resolution_limit: int | None = None + clock_id: str | None = None target_fps: int = 30 def to_dict(self) -> dict: diff --git a/server/src/ledgrab/storage/picture_source_store.py b/server/src/ledgrab/storage/picture_source_store.py index 0835a43..f33e0b8 100644 --- a/server/src/ledgrab/storage/picture_source_store.py +++ b/server/src/ledgrab/storage/picture_source_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import List, Optional, Set +from typing import List, Set from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.database import Database @@ -42,7 +42,7 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]): # ── Helpers ─────────────────────────────────────────────────────── - def _detect_cycle(self, source_stream_id: str, exclude_stream_id: Optional[str] = None) -> bool: + def _detect_cycle(self, source_stream_id: str, exclude_stream_id: str | None = None) -> bool: """Detect if following the source chain from source_stream_id would create a cycle. Args: @@ -77,24 +77,24 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]): self, name: str, stream_type: str, - display_index: Optional[int] = None, - capture_template_id: Optional[str] = None, - target_fps: Optional[int] = None, - source_stream_id: Optional[str] = None, - postprocessing_template_id: Optional[str] = None, - image_asset_id: Optional[str] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, + display_index: int | None = None, + capture_template_id: str | None = None, + target_fps: int | None = None, + source_stream_id: str | None = None, + postprocessing_template_id: str | None = None, + image_asset_id: str | None = None, + description: str | None = None, + tags: List[str] | None = None, # Video fields - video_asset_id: Optional[str] = None, + video_asset_id: str | None = None, loop: bool = True, playback_speed: float = 1.0, - start_time: Optional[float] = None, - end_time: Optional[float] = None, - resolution_limit: Optional[int] = None, - clock_id: Optional[str] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + start_time: float | None = None, + end_time: float | None = None, + resolution_limit: int | None = None, + clock_id: str | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> PictureSource: """Create a new picture source. @@ -188,25 +188,25 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]): def update_stream( self, stream_id: str, - name: Optional[str] = None, - display_index: Optional[int] = None, - capture_template_id: Optional[str] = None, - target_fps: Optional[int] = None, - source_stream_id: Optional[str] = None, - postprocessing_template_id: Optional[str] = None, - image_asset_id: Optional[str] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, + name: str | None = None, + display_index: int | None = None, + capture_template_id: str | None = None, + target_fps: int | None = None, + source_stream_id: str | None = None, + postprocessing_template_id: str | None = None, + image_asset_id: str | None = None, + description: str | None = None, + tags: List[str] | None = None, # Video fields - video_asset_id: Optional[str] = None, - loop: Optional[bool] = None, - playback_speed: Optional[float] = None, - start_time: Optional[float] = None, - end_time: Optional[float] = None, - resolution_limit: Optional[int] = None, - clock_id: Optional[str] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + video_asset_id: str | None = None, + loop: bool | None = None, + playback_speed: float | None = None, + start_time: float | None = None, + end_time: float | None = None, + resolution_limit: int | None = None, + clock_id: str | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> PictureSource: """Update an existing picture source. diff --git a/server/src/ledgrab/storage/postprocessing_template.py b/server/src/ledgrab/storage/postprocessing_template.py index 29fb469..ae6bd75 100644 --- a/server/src/ledgrab/storage/postprocessing_template.py +++ b/server/src/ledgrab/storage/postprocessing_template.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.core.filters.filter_instance import FilterInstance @@ -16,7 +16,7 @@ class PostprocessingTemplate: filters: List[FilterInstance] created_at: datetime updated_at: datetime - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" diff --git a/server/src/ledgrab/storage/postprocessing_template_store.py b/server/src/ledgrab/storage/postprocessing_template_store.py index a23aa4b..240c62f 100644 --- a/server/src/ledgrab/storage/postprocessing_template_store.py +++ b/server/src/ledgrab/storage/postprocessing_template_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.core.filters.filter_instance import FilterInstance from ledgrab.core.filters.registry import FilterRegistry @@ -65,11 +65,11 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]): def create_template( self, name: str, - filters: Optional[List[FilterInstance]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + filters: List[FilterInstance] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> PostprocessingTemplate: self._check_name_unique(name) @@ -105,12 +105,12 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]): def update_template( self, template_id: str, - name: Optional[str] = None, - filters: Optional[List[FilterInstance]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + filters: List[FilterInstance] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> PostprocessingTemplate: template = self.get(template_id) diff --git a/server/src/ledgrab/storage/scene_preset_store.py b/server/src/ledgrab/storage/scene_preset_store.py index ea287e2..6fc874b 100644 --- a/server/src/ledgrab/storage/scene_preset_store.py +++ b/server/src/ledgrab/storage/scene_preset_store.py @@ -1,7 +1,7 @@ """Scene preset storage using SQLite.""" from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.database import Database @@ -43,13 +43,13 @@ class ScenePresetStore(BaseSqliteStore[ScenePreset]): def update_preset( self, preset_id: str, - name: Optional[str] = None, - description: Optional[str] = None, - order: Optional[int] = None, - targets: Optional[List[TargetSnapshot]] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + description: str | None = None, + order: int | None = None, + targets: List[TargetSnapshot] | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> ScenePreset: preset = self.get(preset_id) diff --git a/server/src/ledgrab/storage/sync_clock.py b/server/src/ledgrab/storage/sync_clock.py index e204555..4123297 100644 --- a/server/src/ledgrab/storage/sync_clock.py +++ b/server/src/ledgrab/storage/sync_clock.py @@ -7,7 +7,7 @@ animate in sync and share speed / pause / resume / reset controls. from dataclasses import dataclass, field from datetime import datetime -from typing import List, Optional +from typing import List @dataclass @@ -19,7 +19,7 @@ class SyncClock: speed: float # animation speed multiplier (0.1–10.0) created_at: datetime updated_at: datetime - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" diff --git a/server/src/ledgrab/storage/sync_clock_store.py b/server/src/ledgrab/storage/sync_clock_store.py index c749da5..dd9e860 100644 --- a/server/src/ledgrab/storage/sync_clock_store.py +++ b/server/src/ledgrab/storage/sync_clock_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.database import Database @@ -28,10 +28,10 @@ class SyncClockStore(BaseSqliteStore[SyncClock]): self, name: str, speed: float = 1.0, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> SyncClock: self._check_name_unique(name) cid = f"sc_{uuid.uuid4().hex[:8]}" @@ -57,12 +57,12 @@ class SyncClockStore(BaseSqliteStore[SyncClock]): def update_clock( self, clock_id: str, - name: Optional[str] = None, - speed: Optional[float] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + speed: float | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> SyncClock: clock = self.get(clock_id) diff --git a/server/src/ledgrab/storage/template.py b/server/src/ledgrab/storage/template.py index 8ce1767..e1b3511 100644 --- a/server/src/ledgrab/storage/template.py +++ b/server/src/ledgrab/storage/template.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List @dataclass @@ -15,7 +15,7 @@ class CaptureTemplate: engine_config: Dict[str, Any] created_at: datetime updated_at: datetime - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" diff --git a/server/src/ledgrab/storage/template_store.py b/server/src/ledgrab/storage/template_store.py index e7234b1..7dc84d5 100644 --- a/server/src/ledgrab/storage/template_store.py +++ b/server/src/ledgrab/storage/template_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List from ledgrab.core.capture_engines.factory import EngineRegistry from ledgrab.storage.base_sqlite_store import BaseSqliteStore @@ -69,10 +69,10 @@ class TemplateStore(BaseSqliteStore[CaptureTemplate]): name: str, engine_type: str, engine_config: Dict[str, Any], - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> CaptureTemplate: self._check_name_unique(name) @@ -100,13 +100,13 @@ class TemplateStore(BaseSqliteStore[CaptureTemplate]): def update_template( self, template_id: str, - name: Optional[str] = None, - engine_type: Optional[str] = None, - engine_config: Optional[Dict[str, Any]] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + engine_type: str | None = None, + engine_config: Dict[str, Any] | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> CaptureTemplate: template = self.get(template_id) diff --git a/server/src/ledgrab/storage/utils.py b/server/src/ledgrab/storage/utils.py index f4e1660..7893d9b 100644 --- a/server/src/ledgrab/storage/utils.py +++ b/server/src/ledgrab/storage/utils.py @@ -1,9 +1,7 @@ """Shared utilities for storage layer.""" -from typing import Optional - -def resolve_ref(new_value: Optional[str], current_value: Optional[str]) -> Optional[str]: +def resolve_ref(new_value: str | None, current_value: str | None) -> str | None: """Resolve a reference field update. Handles three cases for nullable reference ID fields: diff --git a/server/src/ledgrab/storage/value_source.py b/server/src/ledgrab/storage/value_source.py index 00296e6..735e47b 100644 --- a/server/src/ledgrab/storage/value_source.py +++ b/server/src/ledgrab/storage/value_source.py @@ -20,7 +20,7 @@ Color types (return_type="color"): from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Dict, List, Optional, Type +from typing import Dict, List, Type @dataclass @@ -34,7 +34,7 @@ class ValueSource: ) created_at: datetime updated_at: datetime - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" @@ -325,7 +325,7 @@ class AnimatedColorValueSource(ValueSource): colors: list = field(default_factory=lambda: [[255, 0, 0], [0, 255, 0], [0, 0, 255]]) speed: float = 10.0 # cycles per minute easing: str = "linear" # linear | step - clock_id: Optional[str] = None # optional SyncClock reference for shared timing + clock_id: str | None = None # optional SyncClock reference for shared timing def to_dict(self) -> dict: d = super().to_dict() diff --git a/server/src/ledgrab/storage/value_source_factories.py b/server/src/ledgrab/storage/value_source_factories.py index 41a4fce..be78770 100644 --- a/server/src/ledgrab/storage/value_source_factories.py +++ b/server/src/ledgrab/storage/value_source_factories.py @@ -21,7 +21,7 @@ sync with ``storage.value_source._VALUE_SOURCE_MAP`` so a new from __future__ import annotations from datetime import datetime -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List from ledgrab.storage.utils import resolve_ref from ledgrab.storage.value_source import ( @@ -44,7 +44,6 @@ from ledgrab.storage.value_source import ( _VALUE_SOURCE_MAP, ) - CreateBuilder = Callable[..., ValueSource] UpdateApplier = Callable[..., None] @@ -59,10 +58,10 @@ def _common( name: str, source_type: str, now: datetime, - description: Optional[str], - tags: Optional[List[str]], - icon: Optional[str], - icon_color: Optional[str], + description: str | None, + tags: List[str] | None, + icon: str | None, + icon_color: str | None, ) -> dict: """Build the kwargs dict shared by every ValueSource subclass __init__.""" return dict( @@ -85,17 +84,17 @@ def _common( # --------------------------------------------------------------------------- -def _build_static(*, common: dict, value: Optional[float] = None, **_) -> ValueSource: +def _build_static(*, common: dict, value: float | None = None, **_) -> ValueSource: return StaticValueSource(**common, value=value if value is not None else 1.0) def _build_animated( *, common: dict, - waveform: Optional[str] = None, - speed: Optional[float] = None, - min_value: Optional[float] = None, - max_value: Optional[float] = None, + waveform: str | None = None, + speed: float | None = None, + min_value: float | None = None, + max_value: float | None = None, **_, ) -> ValueSource: return AnimatedValueSource( @@ -110,13 +109,13 @@ def _build_animated( def _build_audio( *, common: dict, - audio_source_id: Optional[str] = None, - mode: Optional[str] = None, - sensitivity: Optional[float] = None, - smoothing: Optional[float] = None, - min_value: Optional[float] = None, - max_value: Optional[float] = None, - auto_gain: Optional[bool] = None, + audio_source_id: str | None = None, + mode: str | None = None, + sensitivity: float | None = None, + smoothing: float | None = None, + min_value: float | None = None, + max_value: float | None = None, + auto_gain: bool | None = None, **_, ) -> ValueSource: return AudioValueSource( @@ -134,9 +133,9 @@ def _build_audio( def _build_adaptive_time( *, common: dict, - schedule: Optional[list] = None, - min_value: Optional[float] = None, - max_value: Optional[float] = None, + schedule: list | None = None, + min_value: float | None = None, + max_value: float | None = None, **_, ) -> ValueSource: schedule_data = schedule or [] @@ -153,12 +152,12 @@ def _build_adaptive_time( def _build_adaptive_scene( *, common: dict, - picture_source_id: Optional[str] = None, - scene_behavior: Optional[str] = None, - sensitivity: Optional[float] = None, - smoothing: Optional[float] = None, - min_value: Optional[float] = None, - max_value: Optional[float] = None, + picture_source_id: str | None = None, + scene_behavior: str | None = None, + sensitivity: float | None = None, + smoothing: float | None = None, + min_value: float | None = None, + max_value: float | None = None, **_, ) -> ValueSource: return AdaptiveValueSource( @@ -175,12 +174,12 @@ def _build_adaptive_scene( def _build_daylight( *, common: dict, - speed: Optional[float] = None, - use_real_time: Optional[bool] = None, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - min_value: Optional[float] = None, - max_value: Optional[float] = None, + speed: float | None = None, + use_real_time: bool | None = None, + latitude: float | None = None, + longitude: float | None = None, + min_value: float | None = None, + max_value: float | None = None, **_, ) -> ValueSource: return DaylightValueSource( @@ -194,7 +193,7 @@ def _build_daylight( ) -def _build_static_color(*, common: dict, color: Optional[list] = None, **_) -> ValueSource: +def _build_static_color(*, common: dict, color: list | None = None, **_) -> ValueSource: rgb = color if isinstance(color, list) and len(color) == 3 else [255, 255, 255] return StaticColorValueSource(**common, color=rgb) @@ -202,10 +201,10 @@ def _build_static_color(*, common: dict, color: Optional[list] = None, **_) -> V def _build_animated_color( *, common: dict, - colors: Optional[list] = None, - speed: Optional[float] = None, - easing: Optional[str] = None, - clock_id: Optional[str] = None, + colors: list | None = None, + speed: float | None = None, + easing: str | None = None, + clock_id: str | None = None, **_, ) -> ValueSource: seed = ( @@ -222,9 +221,7 @@ def _build_animated_color( ) -def _build_adaptive_time_color( - *, common: dict, schedule: Optional[list] = None, **_ -) -> ValueSource: +def _build_adaptive_time_color(*, common: dict, schedule: list | None = None, **_) -> ValueSource: schedule_data = schedule or [] if len(schedule_data) < 2: raise ValueError("Color schedule requires at least 2 points") @@ -234,12 +231,12 @@ def _build_adaptive_time_color( def _build_ha_entity( *, common: dict, - ha_source_id: Optional[str] = None, - entity_id: Optional[str] = None, - attribute: Optional[str] = None, - min_ha_value: Optional[float] = None, - max_ha_value: Optional[float] = None, - smoothing: Optional[float] = None, + ha_source_id: str | None = None, + entity_id: str | None = None, + attribute: str | None = None, + min_ha_value: float | None = None, + max_ha_value: float | None = None, + smoothing: float | None = None, **_, ) -> ValueSource: if not ha_source_id: @@ -260,9 +257,9 @@ def _build_ha_entity( def _build_gradient_map( *, common: dict, - value_source_id: Optional[str] = None, - gradient_id: Optional[str] = None, - easing: Optional[str] = None, + value_source_id: str | None = None, + gradient_id: str | None = None, + easing: str | None = None, **_, ) -> ValueSource: if not value_source_id: @@ -278,9 +275,9 @@ def _build_gradient_map( def _build_css_extract( *, common: dict, - color_strip_source_id: Optional[str] = None, - led_start: Optional[int] = None, - led_end: Optional[int] = None, + color_strip_source_id: str | None = None, + led_start: int | None = None, + led_end: int | None = None, **_, ) -> ValueSource: if not color_strip_source_id: @@ -296,14 +293,14 @@ def _build_css_extract( def _build_system_metrics( *, common: dict, - metric: Optional[str] = None, - min_value: Optional[float] = None, - max_value: Optional[float] = None, - max_rate: Optional[float] = None, - disk_path: Optional[str] = None, - sensor_label: Optional[str] = None, - poll_interval: Optional[float] = None, - smoothing: Optional[float] = None, + metric: str | None = None, + min_value: float | None = None, + max_value: float | None = None, + max_rate: float | None = None, + disk_path: str | None = None, + sensor_label: str | None = None, + poll_interval: float | None = None, + smoothing: float | None = None, **_, ) -> ValueSource: m = metric or "cpu_load" @@ -344,12 +341,12 @@ def _build_game_event( def _build_http( *, common: dict, - http_endpoint_id: Optional[str] = None, - json_path: Optional[str] = None, - interval_s: Optional[int] = None, - min_value: Optional[float] = None, - max_value: Optional[float] = None, - smoothing: Optional[float] = None, + http_endpoint_id: str | None = None, + json_path: str | None = None, + interval_s: int | None = None, + min_value: float | None = None, + max_value: float | None = None, + smoothing: float | None = None, **_, ) -> ValueSource: if not http_endpoint_id: @@ -393,10 +390,10 @@ def build_source( sid: str, name: str, now: datetime, - description: Optional[str], - tags: Optional[List[str]], - icon: Optional[str], - icon_color: Optional[str], + description: str | None, + tags: List[str] | None, + icon: str | None, + icon_color: str | None, **type_specific: Any, ) -> ValueSource: """Look up the per-type builder and produce a fresh ValueSource.""" diff --git a/server/src/ledgrab/storage/weather_source.py b/server/src/ledgrab/storage/weather_source.py index b56cced..3098bdf 100644 --- a/server/src/ledgrab/storage/weather_source.py +++ b/server/src/ledgrab/storage/weather_source.py @@ -6,7 +6,7 @@ It is a standalone entity referenced by WeatherColorStripSource via weather_sour from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Dict, List, Optional +from typing import Dict, List def _parse_common(data: dict) -> dict: @@ -44,7 +44,7 @@ class WeatherSource: latitude: float = 50.0 longitude: float = 0.0 update_interval: int = 600 # seconds (10 min default) - description: Optional[str] = None + description: str | None = None tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" diff --git a/server/src/ledgrab/storage/weather_source_store.py b/server/src/ledgrab/storage/weather_source_store.py index 1b673ae..553d8df 100644 --- a/server/src/ledgrab/storage/weather_source_store.py +++ b/server/src/ledgrab/storage/weather_source_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.database import Database @@ -30,14 +30,14 @@ class WeatherSourceStore(BaseSqliteStore[WeatherSource]): self, name: str, provider: str = "open_meteo", - provider_config: Optional[dict] = None, + provider_config: dict | None = None, latitude: float = 50.0, longitude: float = 0.0, update_interval: int = 600, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> WeatherSource: from ledgrab.core.weather.weather_provider import PROVIDER_REGISTRY @@ -79,16 +79,16 @@ class WeatherSourceStore(BaseSqliteStore[WeatherSource]): def update_source( self, source_id: str, - name: Optional[str] = None, - provider: Optional[str] = None, - provider_config: Optional[dict] = None, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - update_interval: Optional[int] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + name: str | None = None, + provider: str | None = None, + provider_config: dict | None = None, + latitude: float | None = None, + longitude: float | None = None, + update_interval: int | None = None, + description: str | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, ) -> WeatherSource: existing = self.get(source_id) diff --git a/server/src/ledgrab/storage/wled_output_target.py b/server/src/ledgrab/storage/wled_output_target.py index 824c451..0b15866 100644 --- a/server/src/ledgrab/storage/wled_output_target.py +++ b/server/src/ledgrab/storage/wled_output_target.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.storage.bindable import BindableFloat from ledgrab.storage.output_target import OutputTarget @@ -82,9 +82,9 @@ class WledOutputTarget(OutputTarget, type_key="led"): adaptive_fps=None, protocol=None, description=None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, **_kwargs, ) -> None: """Apply mutable field updates for WLED targets.""" diff --git a/server/src/ledgrab/storage/z2m_light_output_target.py b/server/src/ledgrab/storage/z2m_light_output_target.py index 78665fb..259756e 100644 --- a/server/src/ledgrab/storage/z2m_light_output_target.py +++ b/server/src/ledgrab/storage/z2m_light_output_target.py @@ -7,7 +7,7 @@ friendly names assigned in Z2M. from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import List, Optional +from typing import List from ledgrab.storage.bindable import BindableFloat from ledgrab.storage.output_target import OutputTarget @@ -159,9 +159,9 @@ class Z2MLightOutputTarget(OutputTarget, type_key="z2m_light"): color_tolerance=None, stop_action=None, description=None, - tags: Optional[List[str]] = None, - icon: Optional[str] = None, - icon_color: Optional[str] = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, **_kwargs, ) -> None: super().update_fields( diff --git a/server/src/ledgrab/utils/image_codec.py b/server/src/ledgrab/utils/image_codec.py index e67659c..2682d04 100644 --- a/server/src/ledgrab/utils/image_codec.py +++ b/server/src/ledgrab/utils/image_codec.py @@ -9,7 +9,7 @@ All functions work with numpy RGB arrays (H, W, 3) uint8. import base64 import io from pathlib import Path -from typing import Tuple, Union +from typing import Tuple import numpy as np @@ -114,7 +114,7 @@ def resize_down(image: np.ndarray, max_width: int) -> np.ndarray: # --------------------------------------------------------------------------- -def load_image_file(path: Union[str, Path]) -> np.ndarray: +def load_image_file(path: str | Path) -> np.ndarray: """Load an image file and return as RGB numpy array.""" path = str(path) diff --git a/server/src/ledgrab/utils/metrics/android_provider.py b/server/src/ledgrab/utils/metrics/android_provider.py index 7ac612b..6007fb8 100644 --- a/server/src/ledgrab/utils/metrics/android_provider.py +++ b/server/src/ledgrab/utils/metrics/android_provider.py @@ -17,7 +17,6 @@ from __future__ import annotations import os from dataclasses import dataclass -from typing import Optional import glob @@ -42,7 +41,7 @@ class _CpuSample: busy: int -def _read_proc_stat() -> Optional[_CpuSample]: +def _read_proc_stat() -> _CpuSample | None: """Aggregate CPU jiffies from the first ``cpu`` line of /proc/stat.""" try: with open("/proc/stat", "r") as f: @@ -64,7 +63,7 @@ def _read_proc_stat() -> Optional[_CpuSample]: return _CpuSample(total=total, busy=total - idle) -def _read_proc_self_stat_jiffies() -> Optional[int]: +def _read_proc_self_stat_jiffies() -> int | None: """Return user+system jiffies for the current process, or None on failure.""" try: with open("/proc/self/stat", "rb") as f: @@ -119,7 +118,7 @@ def _read_meminfo() -> MemorySnapshot: ) -def _read_int_file(path: str) -> Optional[int]: +def _read_int_file(path: str) -> int | None: """Read a sysfs node holding a single integer; None on failure.""" try: with open(path, "r") as f: @@ -128,7 +127,7 @@ def _read_int_file(path: str) -> Optional[int]: return None -def _read_text_file(path: str) -> Optional[str]: +def _read_text_file(path: str) -> str | None: """Read a sysfs node holding a short string; None on failure.""" try: with open(path, "r") as f: @@ -137,7 +136,7 @@ def _read_text_file(path: str) -> Optional[str]: return None -def _read_battery() -> tuple[Optional[float], Optional[float]]: +def _read_battery() -> tuple[float | None, float | None]: """Return (capacity_percent, temp_celsius). Either may be None.""" base = "/sys/class/power_supply/battery" capacity = _read_int_file(f"{base}/capacity") @@ -148,14 +147,14 @@ def _read_battery() -> tuple[Optional[float], Optional[float]]: return pct, temp -def _read_cpu_temp_c() -> Optional[float]: +def _read_cpu_temp_c() -> float | None: """Hottest CPU thermal zone in °C, or None if /sys/class/thermal/* is empty. Walks every ``thermal_zone*/temp`` (millidegrees) and returns the max. Filters by zone type when possible to skip battery/skin sensors that would otherwise dominate the reading. """ - hottest: Optional[float] = None + hottest: float | None = None for zone_dir in glob.glob("/sys/class/thermal/thermal_zone*"): zone_type = (_read_text_file(f"{zone_dir}/type") or "").lower() # Skip non-CPU zones — battery/skin/usb sensors are noise here. @@ -199,9 +198,9 @@ class AndroidMetricsProvider: def __init__(self) -> None: self._cpu_count = os.cpu_count() or 1 # Prime the deltas so the first real sample is meaningful. - self._last_host: Optional[_CpuSample] = _read_proc_stat() - self._last_proc_jiffies: Optional[int] = _read_proc_self_stat_jiffies() - self._last_host_total: Optional[int] = self._last_host.total if self._last_host else None + self._last_host: _CpuSample | None = _read_proc_stat() + self._last_proc_jiffies: int | None = _read_proc_self_stat_jiffies() + self._last_host_total: int | None = self._last_host.total if self._last_host else None def cpu_percent(self) -> float: sample = _read_proc_stat() diff --git a/server/src/ledgrab/utils/metrics/psutil_provider.py b/server/src/ledgrab/utils/metrics/psutil_provider.py index 7aa8980..bf98c32 100644 --- a/server/src/ledgrab/utils/metrics/psutil_provider.py +++ b/server/src/ledgrab/utils/metrics/psutil_provider.py @@ -7,7 +7,6 @@ import platform import subprocess import threading import time -from typing import Optional from .types import MemorySnapshot, ProcessSnapshot, ThermalSnapshot @@ -32,7 +31,7 @@ class PsutilMetricsProvider: # psutil has no sensors_temperatures() on Windows, so fall back to a # throttled WMI/LHM reader running in a daemon thread. Disabled in # tests via LEDGRAB_DISABLE_WIN_TEMP. - self._windows_temp: Optional[_WindowsCpuTemp] = ( + self._windows_temp: _WindowsCpuTemp | None = ( _WindowsCpuTemp() if platform.system() == "Windows" and not os.environ.get("LEDGRAB_DISABLE_WIN_TEMP") else None @@ -144,7 +143,7 @@ _WIN_TEMP_POWERSHELL = ( ) -def _query_windows_cpu_temp() -> Optional[float]: +def _query_windows_cpu_temp() -> float | None: """Run the PowerShell WMI probe once and parse the single-line result. Returns None on any failure. Rejects wildly out-of-range values to @@ -192,14 +191,14 @@ class _WindowsCpuTemp: MAX_FAILURES = 3 def __init__(self) -> None: - self._cached_c: Optional[float] = None + self._cached_c: float | None = None self._last_refresh: float = 0.0 self._refreshing: bool = False self._disabled: bool = False self._failures: int = 0 self._lock = threading.Lock() - def get(self) -> Optional[float]: + def get(self) -> float | None: if self._disabled: return None now = time.monotonic() diff --git a/server/src/ledgrab/utils/metrics/types.py b/server/src/ledgrab/utils/metrics/types.py index 4b10f5a..7684307 100644 --- a/server/src/ledgrab/utils/metrics/types.py +++ b/server/src/ledgrab/utils/metrics/types.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Optional, Protocol +from typing import Protocol @dataclass(frozen=True) @@ -29,9 +29,9 @@ class ThermalSnapshot: the battery node. ``None`` means "not available", *not* "zero". """ - battery_percent: Optional[float] = None - battery_temp_c: Optional[float] = None - cpu_temp_c: Optional[float] = None # max across all thermal zones + battery_percent: float | None = None + battery_temp_c: float | None = None + cpu_temp_c: float | None = None # max across all thermal zones class MetricsProvider(Protocol): diff --git a/server/src/ledgrab/utils/safe_source.py b/server/src/ledgrab/utils/safe_source.py index 2dfeef8..ed2fb3a 100644 --- a/server/src/ledgrab/utils/safe_source.py +++ b/server/src/ledgrab/utils/safe_source.py @@ -22,7 +22,6 @@ from fastapi import HTTPException from ledgrab.utils.net_classify import is_blocked_for_ssrf - # Image file extensions considered safe to serve _IMAGE_EXTENSIONS = frozenset( { diff --git a/server/src/ledgrab/utils/secret_box.py b/server/src/ledgrab/utils/secret_box.py index 4f0edd2..8f0a401 100644 --- a/server/src/ledgrab/utils/secret_box.py +++ b/server/src/ledgrab/utils/secret_box.py @@ -15,7 +15,6 @@ import os import secrets import sys from pathlib import Path -from typing import Optional from cryptography.hazmat.primitives.ciphers.aead import AESGCM @@ -29,8 +28,8 @@ _KEY_BYTES = 32 # AES-256 _DEFAULT_KEY_PATH = Path("data/.secret_key") -_cached_key: Optional[bytes] = None -_cached_path: Optional[Path] = None +_cached_key: bytes | None = None +_cached_path: Path | None = None def _key_path() -> Path: diff --git a/server/src/ledgrab/utils/url_scheme.py b/server/src/ledgrab/utils/url_scheme.py index faf64c6..ceeeea7 100644 --- a/server/src/ledgrab/utils/url_scheme.py +++ b/server/src/ledgrab/utils/url_scheme.py @@ -24,7 +24,6 @@ import re from ledgrab.utils.net_classify import is_local_for_http_default - # RFC 3986 scheme grammar: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) followed # by ":". Used to recognise URIs like ``data:...``, ``javascript:...``, # ``mailto:...`` that omit the ``//`` authority component — we must not diff --git a/server/src/ledgrab/utils/win_shutdown.py b/server/src/ledgrab/utils/win_shutdown.py index 449d213..d86935b 100644 --- a/server/src/ledgrab/utils/win_shutdown.py +++ b/server/src/ledgrab/utils/win_shutdown.py @@ -35,7 +35,7 @@ import ctypes import logging import threading from ctypes import wintypes -from typing import Callable, Optional +from typing import Callable from ledgrab.utils.platform import is_windows @@ -222,8 +222,8 @@ class WindowsShutdownGuard: self._app_name = app_name self._wait_seconds = wait_seconds - self._hwnd: Optional[wintypes.HWND] = None - self._thread: Optional[threading.Thread] = None + self._hwnd: wintypes.HWND | None = None + self._thread: threading.Thread | None = None self._ready = threading.Event() self._fired = False # idempotency: only trigger on_shutdown once diff --git a/server/tests/api/routes/test_devices_routes.py b/server/tests/api/routes/test_devices_routes.py index edac22b..703c56f 100644 --- a/server/tests/api/routes/test_devices_routes.py +++ b/server/tests/api/routes/test_devices_routes.py @@ -16,7 +16,6 @@ from ledgrab.storage.output_target_store import OutputTargetStore from ledgrab.core.processing.processor_manager import ProcessorManager from ledgrab.api import dependencies as deps - # --------------------------------------------------------------------------- # App + fixtures (isolated from the real main app) # --------------------------------------------------------------------------- diff --git a/server/tests/api/routes/test_game_integration_routes.py b/server/tests/api/routes/test_game_integration_routes.py index ec5e150..542a2e8 100644 --- a/server/tests/api/routes/test_game_integration_routes.py +++ b/server/tests/api/routes/test_game_integration_routes.py @@ -16,7 +16,6 @@ from ledgrab.core.game_integration.events import GameEvent from ledgrab.storage.game_integration_store import GameIntegrationStore from ledgrab.api import dependencies as deps - # --------------------------------------------------------------------------- # Test adapter for ingestion tests # --------------------------------------------------------------------------- diff --git a/server/tests/api/routes/test_output_target_response_registry.py b/server/tests/api/routes/test_output_target_response_registry.py index e5bdee8..575eaf7 100644 --- a/server/tests/api/routes/test_output_target_response_registry.py +++ b/server/tests/api/routes/test_output_target_response_registry.py @@ -19,7 +19,6 @@ from ledgrab.storage.ha_light_output_target import HALightOutputTarget from ledgrab.storage.wled_output_target import WledOutputTarget from ledgrab.storage.z2m_light_output_target import Z2MLightOutputTarget - EXPECTED_TARGET_CLASSES = {WledOutputTarget, HALightOutputTarget, Z2MLightOutputTarget} diff --git a/server/tests/api/routes/test_webhooks_routes.py b/server/tests/api/routes/test_webhooks_routes.py index e755cf7..3a1f076 100644 --- a/server/tests/api/routes/test_webhooks_routes.py +++ b/server/tests/api/routes/test_webhooks_routes.py @@ -6,7 +6,6 @@ import pytest from ledgrab.api.routes.webhooks import _check_rate_limit, _rate_hits - # --------------------------------------------------------------------------- # Rate limiter unit tests (pure function, no HTTP) # --------------------------------------------------------------------------- diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 883b6a5..7d256a5 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -58,7 +58,6 @@ from ledgrab.storage.automation import Automation # noqa: E402 from ledgrab.storage.automation_store import AutomationStore # noqa: E402 from ledgrab.storage.value_source_store import ValueSourceStore # noqa: E402 - # --------------------------------------------------------------------------- # Directory / path fixtures # --------------------------------------------------------------------------- diff --git a/server/tests/core/capture/test_edge_interpolation.py b/server/tests/core/capture/test_edge_interpolation.py index d00b260..88af7d7 100644 --- a/server/tests/core/capture/test_edge_interpolation.py +++ b/server/tests/core/capture/test_edge_interpolation.py @@ -9,7 +9,6 @@ from ledgrab.core.capture.edge_interpolation import ( fallback_edge_to_leds, ) - # --------------------------------------------------------------------------- # average_edge_to_leds # --------------------------------------------------------------------------- diff --git a/server/tests/core/processing/test_effect_registry.py b/server/tests/core/processing/test_effect_registry.py index 897e813..e25fac7 100644 --- a/server/tests/core/processing/test_effect_registry.py +++ b/server/tests/core/processing/test_effect_registry.py @@ -17,7 +17,6 @@ from ledgrab.core.processing.effect_stream import ( _effect_renderer, ) - EXPECTED_EFFECTS = frozenset( { "fire", diff --git a/server/tests/core/processing/test_metric_readers.py b/server/tests/core/processing/test_metric_readers.py index cebfed5..77a0977 100644 --- a/server/tests/core/processing/test_metric_readers.py +++ b/server/tests/core/processing/test_metric_readers.py @@ -21,7 +21,6 @@ from ledgrab.core.processing.metric_readers import ( get_spec, ) - EXPECTED_METRICS = { "cpu_load", "ram_usage", diff --git a/server/tests/core/test_audio_filters.py b/server/tests/core/test_audio_filters.py index 3928c45..5ee973e 100644 --- a/server/tests/core/test_audio_filters.py +++ b/server/tests/core/test_audio_filters.py @@ -13,7 +13,6 @@ import ledgrab.core.audio.filters # noqa: F401 from ledgrab.core.filters.filter_instance import FilterInstance - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/server/tests/core/test_automation_engine.py b/server/tests/core/test_automation_engine.py index 7abbbfc..609e690 100644 --- a/server/tests/core/test_automation_engine.py +++ b/server/tests/core/test_automation_engine.py @@ -17,7 +17,6 @@ from ledgrab.storage.automation import ( ) from ledgrab.storage.automation_store import AutomationStore - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- diff --git a/server/tests/core/test_automation_rule_handlers.py b/server/tests/core/test_automation_rule_handlers.py index 9b6dc54..04a3a50 100644 --- a/server/tests/core/test_automation_rule_handlers.py +++ b/server/tests/core/test_automation_rule_handlers.py @@ -27,7 +27,6 @@ from ledgrab.storage.automation import ( WebhookRule, ) - EXPECTED_RULE_TYPES = { StartupRule, ApplicationRule, diff --git a/server/tests/core/test_cs2_adapter.py b/server/tests/core/test_cs2_adapter.py index d1f78d0..2bca97d 100644 --- a/server/tests/core/test_cs2_adapter.py +++ b/server/tests/core/test_cs2_adapter.py @@ -4,7 +4,6 @@ import pytest from ledgrab.core.game_integration.adapters.cs2_adapter import CS2Adapter - # ── Realistic CS2 GSI payload samples ──────────────────────────────────── diff --git a/server/tests/core/test_game_event_css.py b/server/tests/core/test_game_event_css.py index 17f2f4c..8f13bff 100644 --- a/server/tests/core/test_game_event_css.py +++ b/server/tests/core/test_game_event_css.py @@ -14,7 +14,6 @@ from ledgrab.storage.color_strip_source import ( GameEventColorStripSource, ) - # ── Helpers ────────────────────────────────────────────────────────── diff --git a/server/tests/core/test_game_event_value_source.py b/server/tests/core/test_game_event_value_source.py index 225c382..ff654fe 100644 --- a/server/tests/core/test_game_event_value_source.py +++ b/server/tests/core/test_game_event_value_source.py @@ -13,7 +13,6 @@ from ledgrab.storage.value_source import ( ValueSource, ) - # --------------------------------------------------------------------------- # GameEventValueSource model tests (Task 5) # --------------------------------------------------------------------------- diff --git a/server/tests/core/test_ha_light_target_processor.py b/server/tests/core/test_ha_light_target_processor.py index 6c472f4..558f986 100644 --- a/server/tests/core/test_ha_light_target_processor.py +++ b/server/tests/core/test_ha_light_target_processor.py @@ -2,7 +2,7 @@ import asyncio from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Tuple import numpy as np import pytest @@ -12,7 +12,6 @@ from ledgrab.core.processing.target_processor import TargetContext from ledgrab.storage.bindable import BindableFloat from ledgrab.storage.ha_light_output_target import HALightMapping - # --------------------------------------------------------------------------- # Test doubles # --------------------------------------------------------------------------- @@ -115,9 +114,9 @@ class _FakeCSSManager: def _make_ctx( *, - ha_manager: Optional[_FakeHAManager] = None, - css_manager: Optional[_FakeCSSManager] = None, - vs_manager: Optional[_FakeVSManager] = None, + ha_manager: _FakeHAManager | None = None, + css_manager: _FakeCSSManager | None = None, + vs_manager: _FakeVSManager | None = None, ) -> TargetContext: return TargetContext( live_stream_manager=None, # type: ignore[arg-type] diff --git a/server/tests/core/test_mapping_adapter.py b/server/tests/core/test_mapping_adapter.py index 4b4526b..92d008b 100644 --- a/server/tests/core/test_mapping_adapter.py +++ b/server/tests/core/test_mapping_adapter.py @@ -11,7 +11,6 @@ from ledgrab.core.game_integration.mapping_adapter import ( validate_adapter_yaml, ) - # ── YAML validation tests ─────────────────────────────────────────────── diff --git a/server/tests/e2e/test_auth_flow.py b/server/tests/e2e/test_auth_flow.py index d923faf..3ae21f9 100644 --- a/server/tests/e2e/test_auth_flow.py +++ b/server/tests/e2e/test_auth_flow.py @@ -8,8 +8,6 @@ helpers to make unauthenticated requests by temporarily removing the header. """ - - def _unauth_get(client, url): """Make a GET request without the Authorization header.""" saved = client.headers.pop("Authorization", None) @@ -54,7 +52,9 @@ class TestAuthEnforcement: def test_request_with_wrong_key_returns_401(self, client): """Protected endpoint with an incorrect API key returns 401.""" resp = _with_header( - client, "GET", "/api/v1/devices", + client, + "GET", + "/api/v1/devices", auth_value="Bearer wrong-key-12345", ) assert resp.status_code == 401 @@ -82,7 +82,9 @@ class TestAuthEnforcement: def test_post_without_auth_returns_401(self, client): """Creating a device without auth fails.""" resp = _unauth_request( - client, "POST", "/api/v1/devices", + client, + "POST", + "/api/v1/devices", json={ "name": "Unauthorized Device", "url": "mock://test", @@ -115,7 +117,9 @@ class TestAuthEnforcement: def test_malformed_bearer_token_returns_401_or_403(self, client): """A malformed Authorization header is rejected.""" resp = _with_header( - client, "GET", "/api/v1/devices", + client, + "GET", + "/api/v1/devices", auth_value="just-a-key", ) # FastAPI's HTTPBearer returns 403 for malformed format, diff --git a/server/tests/e2e/test_device_flow.py b/server/tests/e2e/test_device_flow.py index bf2dbad..e173c2b 100644 --- a/server/tests/e2e/test_device_flow.py +++ b/server/tests/e2e/test_device_flow.py @@ -5,7 +5,6 @@ create -> get -> update -> brightness -> power -> delete -> verify gone. """ - class TestDeviceLifecycle: """A user creates a device, inspects it, modifies it, and deletes it.""" @@ -76,12 +75,15 @@ class TestDeviceLifecycle: def test_create_multiple_devices_and_list(self, client): """Creating multiple devices shows all in the list.""" for i in range(3): - resp = client.post("/api/v1/devices", json={ - "name": f"Device {i}", - "url": "mock://test", - "device_type": "mock", - "led_count": 30, - }) + resp = client.post( + "/api/v1/devices", + json={ + "name": f"Device {i}", + "url": "mock://test", + "device_type": "mock", + "led_count": 30, + }, + ) assert resp.status_code == 201 resp = client.get("/api/v1/devices") @@ -108,13 +110,16 @@ class TestDeviceLifecycle: def test_update_tags(self, client): """Tags can be updated independently.""" - resp = client.post("/api/v1/devices", json={ - "name": "Tag Device", - "url": "mock://test", - "device_type": "mock", - "led_count": 10, - "tags": ["original"], - }) + resp = client.post( + "/api/v1/devices", + json={ + "name": "Tag Device", + "url": "mock://test", + "device_type": "mock", + "led_count": 10, + "tags": ["original"], + }, + ) device_id = resp.json()["id"] resp = client.put( diff --git a/server/tests/storage/test_base_store.py b/server/tests/storage/test_base_store.py index f1c2ddf..3d70310 100644 --- a/server/tests/storage/test_base_store.py +++ b/server/tests/storage/test_base_store.py @@ -9,7 +9,6 @@ import pytest from ledgrab.storage.base_store import BaseJsonStore, EntityNotFoundError - # --------------------------------------------------------------------------- # Minimal concrete store for testing the base class # --------------------------------------------------------------------------- diff --git a/server/tests/storage/test_device_store.py b/server/tests/storage/test_device_store.py index f44a43d..121345f 100644 --- a/server/tests/storage/test_device_store.py +++ b/server/tests/storage/test_device_store.py @@ -6,7 +6,6 @@ import pytest from ledgrab.storage.device_store import Device, DeviceStore - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- diff --git a/server/tests/storage/test_value_source_factories.py b/server/tests/storage/test_value_source_factories.py index 9407a1e..113bf84 100644 --- a/server/tests/storage/test_value_source_factories.py +++ b/server/tests/storage/test_value_source_factories.py @@ -19,7 +19,6 @@ from ledgrab.storage.value_source import ( _VALUE_SOURCE_MAP, ) - # --------------------------------------------------------------------------- # Coverage / shape # --------------------------------------------------------------------------- diff --git a/server/tests/test_camera_engine.py b/server/tests/test_camera_engine.py index f28f579..c56d042 100644 --- a/server/tests/test_camera_engine.py +++ b/server/tests/test_camera_engine.py @@ -15,7 +15,6 @@ import pytest from ledgrab.core.capture_engines import camera_engine as ce - # --------------------------------------------------------------------------- # _parse_resolution — pure function, no stubs needed # --------------------------------------------------------------------------- diff --git a/server/tests/test_composite_nesting.py b/server/tests/test_composite_nesting.py index 0530056..1c72563 100644 --- a/server/tests/test_composite_nesting.py +++ b/server/tests/test_composite_nesting.py @@ -5,7 +5,6 @@ import pytest from ledgrab.storage.color_strip_store import ColorStripStore, MAX_COMPOSITE_DEPTH from ledgrab.storage.database import Database - # ── Fixtures ────────────────────────────────────────────────────────── diff --git a/server/tests/test_ddp_led_client.py b/server/tests/test_ddp_led_client.py index 5d8085b..a38b153 100644 --- a/server/tests/test_ddp_led_client.py +++ b/server/tests/test_ddp_led_client.py @@ -16,7 +16,6 @@ from ledgrab.core.devices.ddp_provider import DDPDeviceProvider from ledgrab.core.devices.device_config import DDPConfig from ledgrab.core.devices.led_client import ProviderDeps - # ============================================================================ # parse_ddp_url # ============================================================================ diff --git a/server/tests/test_events_ws_parity.py b/server/tests/test_events_ws_parity.py index 0a893b7..9963088 100644 --- a/server/tests/test_events_ws_parity.py +++ b/server/tests/test_events_ws_parity.py @@ -15,7 +15,6 @@ from pathlib import Path import pytest - _REPO_ROOT = Path(__file__).resolve().parents[1] _SERVER_SRC = _REPO_ROOT / "src" / "ledgrab" _EVENTS_WS = _SERVER_SRC / "static" / "js" / "core" / "events-ws.ts" diff --git a/server/tests/test_govee.py b/server/tests/test_govee.py index c3a5064..102be65 100644 --- a/server/tests/test_govee.py +++ b/server/tests/test_govee.py @@ -19,7 +19,6 @@ from ledgrab.core.devices.govee_client import ( from ledgrab.core.devices.govee_provider import GoveeDeviceProvider from ledgrab.core.devices.led_client import ProviderDeps - # ============================================================================ # URL parsing # ============================================================================ diff --git a/server/tests/test_group_device.py b/server/tests/test_group_device.py index f3e707e..2e27bb0 100644 --- a/server/tests/test_group_device.py +++ b/server/tests/test_group_device.py @@ -8,7 +8,6 @@ from ledgrab.core.devices.led_client import ProviderDeps from ledgrab.storage.database import Database from ledgrab.storage.device_store import Device, DeviceStore - # ── Fixtures ────────────────────────────────────────────────────────── diff --git a/server/tests/test_lifx.py b/server/tests/test_lifx.py index bd162d4..5b5c90d 100644 --- a/server/tests/test_lifx.py +++ b/server/tests/test_lifx.py @@ -27,7 +27,6 @@ from ledgrab.core.devices.lifx_client import ( ) from ledgrab.core.devices.lifx_provider import LIFXDeviceProvider - # ============================================================================ # URL parsing # ============================================================================ diff --git a/server/tests/test_nanoleaf.py b/server/tests/test_nanoleaf.py index 4be78bc..1fbf1dc 100644 --- a/server/tests/test_nanoleaf.py +++ b/server/tests/test_nanoleaf.py @@ -20,7 +20,6 @@ from ledgrab.core.devices.nanoleaf_client import ( ) from ledgrab.core.devices.nanoleaf_provider import NanoleafDeviceProvider - # ============================================================================ # URL parsing # ============================================================================ diff --git a/server/tests/test_opc.py b/server/tests/test_opc.py index c5c6241..dd87e91 100644 --- a/server/tests/test_opc.py +++ b/server/tests/test_opc.py @@ -18,7 +18,6 @@ from ledgrab.core.devices.opc_client import ( ) from ledgrab.core.devices.opc_provider import OPCDeviceProvider - # ============================================================================ # URL parsing # ============================================================================ diff --git a/server/tests/test_serial_transport.py b/server/tests/test_serial_transport.py index 1398cc0..6d04b49 100644 --- a/server/tests/test_serial_transport.py +++ b/server/tests/test_serial_transport.py @@ -16,7 +16,6 @@ from ledgrab.core.devices.serial_transport import ( port_exists, ) - # ── URL parsing ──────────────────────────────────────────────────── diff --git a/server/tests/test_wiz.py b/server/tests/test_wiz.py index 599eda8..1bdbfb3 100644 --- a/server/tests/test_wiz.py +++ b/server/tests/test_wiz.py @@ -19,7 +19,6 @@ from ledgrab.core.devices.wiz_client import ( ) from ledgrab.core.devices.wiz_provider import WiZDeviceProvider - # ============================================================================ # parse_wiz_url # ============================================================================ diff --git a/server/tests/test_ws_auth.py b/server/tests/test_ws_auth.py index f7de094..95e0893 100644 --- a/server/tests/test_ws_auth.py +++ b/server/tests/test_ws_auth.py @@ -9,7 +9,6 @@ from fastapi.testclient import TestClient import ledgrab.config as config_mod from ledgrab.config import AuthConfig, Config, ServerConfig, StorageConfig - # --------------------------------------------------------------------------- # Minimal app with a single WS endpoint using verify_ws_auth # --------------------------------------------------------------------------- diff --git a/server/tests/test_yeelight.py b/server/tests/test_yeelight.py index 7c8a1b9..c57c820 100644 --- a/server/tests/test_yeelight.py +++ b/server/tests/test_yeelight.py @@ -20,7 +20,6 @@ from ledgrab.core.devices.yeelight_client import ( ) from ledgrab.core.devices.yeelight_provider import YeelightDeviceProvider - # ============================================================================ # parse_yeelight_url # ============================================================================