feat: daylight tz, camera engine, value stream + modal/UI polish
- daylight: new daylight_settings module + daylight-tz frontend helper; expanded daylight_stream behavior - camera engine: capture path additions plus new test_camera_engine suite - value stream: schema + processing updates (~178 lines) - color strip: drop cycle effect (cycle.py / color-cycle.ts removed), tighten static path - modal CSS: large refactor (+883), components.css polish (+110) - templates: settings, css-editor, value-source-editor, test-template, display-picker, image-lightbox - frontend core: state, modal, icons, graph-nodes, app - frontend features: displays, streams, streams-capture-templates, value-sources, settings, color-strips/cards - locales: en/ru/zh - storage: color_strip, picture_source, value_source loaders touched - preferences/sync_clocks/picture_sources routes; home_assistant + templates schemas
This commit is contained in:
@@ -4,7 +4,6 @@ from ledgrab.api.schemas.color_strip_sources import (
|
|||||||
ApiInputCSSResponse,
|
ApiInputCSSResponse,
|
||||||
AudioCSSResponse,
|
AudioCSSResponse,
|
||||||
CandlelightCSSResponse,
|
CandlelightCSSResponse,
|
||||||
ColorCycleCSSResponse,
|
|
||||||
ColorStop as ColorStopSchema,
|
ColorStop as ColorStopSchema,
|
||||||
ColorStripSourceResponse,
|
ColorStripSourceResponse,
|
||||||
CompositeCSSResponse,
|
CompositeCSSResponse,
|
||||||
@@ -31,7 +30,6 @@ from ledgrab.storage.color_strip_source import (
|
|||||||
ApiInputColorStripSource,
|
ApiInputColorStripSource,
|
||||||
AudioColorStripSource,
|
AudioColorStripSource,
|
||||||
CandlelightColorStripSource,
|
CandlelightColorStripSource,
|
||||||
ColorCycleColorStripSource,
|
|
||||||
CompositeColorStripSource,
|
CompositeColorStripSource,
|
||||||
DaylightColorStripSource,
|
DaylightColorStripSource,
|
||||||
EffectColorStripSource,
|
EffectColorStripSource,
|
||||||
@@ -121,10 +119,6 @@ _RESPONSE_MAP: dict = {
|
|||||||
easing=s.easing,
|
easing=s.easing,
|
||||||
gradient_id=s.gradient_id,
|
gradient_id=s.gradient_id,
|
||||||
),
|
),
|
||||||
ColorCycleColorStripSource: lambda s, kw: ColorCycleCSSResponse(
|
|
||||||
**kw,
|
|
||||||
colors=[list(c) for c in s.colors],
|
|
||||||
),
|
|
||||||
EffectColorStripSource: lambda s, kw: EffectCSSResponse(
|
EffectColorStripSource: lambda s, kw: EffectCSSResponse(
|
||||||
**kw,
|
**kw,
|
||||||
effect_type=s.effect_type,
|
effect_type=s.effect_type,
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ router = APIRouter()
|
|||||||
_PREVIEW_ALLOWED_TYPES = {
|
_PREVIEW_ALLOWED_TYPES = {
|
||||||
"static",
|
"static",
|
||||||
"gradient",
|
"gradient",
|
||||||
"color_cycle",
|
|
||||||
"effect",
|
"effect",
|
||||||
"daylight",
|
"daylight",
|
||||||
"candlelight",
|
"candlelight",
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ async def get_ha_status(
|
|||||||
name=source.name,
|
name=source.name,
|
||||||
connected=connected,
|
connected=connected,
|
||||||
entity_count=status["entity_count"] if status else 0,
|
entity_count=status["entity_count"] if status else 0,
|
||||||
|
host=source.host or "",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from fastapi.responses import Response
|
|||||||
from ledgrab.api.auth import AuthRequired
|
from ledgrab.api.auth import AuthRequired
|
||||||
from ledgrab.api.dependencies import (
|
from ledgrab.api.dependencies import (
|
||||||
fire_entity_event,
|
fire_entity_event,
|
||||||
|
get_color_strip_store,
|
||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
get_pp_template_store,
|
get_pp_template_store,
|
||||||
@@ -37,6 +38,7 @@ from ledgrab.api.schemas.picture_sources import (
|
|||||||
)
|
)
|
||||||
from ledgrab.core.capture_engines import EngineRegistry
|
from ledgrab.core.capture_engines import EngineRegistry
|
||||||
from ledgrab.core.filters import FilterRegistry, ImagePool
|
from ledgrab.core.filters import FilterRegistry, ImagePool
|
||||||
|
from ledgrab.storage.color_strip_store import ColorStripStore
|
||||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||||
from ledgrab.storage.template_store import TemplateStore
|
from ledgrab.storage.template_store import TemplateStore
|
||||||
from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
|
from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||||
@@ -361,11 +363,12 @@ async def delete_picture_source(
|
|||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: PictureSourceStore = Depends(get_picture_source_store),
|
store: PictureSourceStore = Depends(get_picture_source_store),
|
||||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
):
|
):
|
||||||
"""Delete a picture source."""
|
"""Delete a picture source."""
|
||||||
try:
|
try:
|
||||||
# Check if any target references this stream
|
# Check if any target transitively references this stream via a CSS
|
||||||
target_names = store.get_targets_referencing(stream_id, target_store)
|
target_names = store.get_targets_referencing(stream_id, target_store, css_store)
|
||||||
if target_names:
|
if target_names:
|
||||||
names = ", ".join(target_names)
|
names = ", ".join(target_names)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -373,6 +376,16 @@ async def delete_picture_source(
|
|||||||
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. "
|
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. "
|
||||||
"Please reassign those targets before deleting.",
|
"Please reassign those targets before deleting.",
|
||||||
)
|
)
|
||||||
|
# Block when any CSS still references this picture source, even if no
|
||||||
|
# target depends on it — deletion would leave the CSS broken.
|
||||||
|
css_refs = css_store.get_referencing_picture_source(stream_id)
|
||||||
|
if css_refs:
|
||||||
|
css_names = ", ".join(css.name for css in css_refs)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Cannot delete picture source: it is used by color strip source(s): "
|
||||||
|
f"{css_names}. Please reassign or delete those first.",
|
||||||
|
)
|
||||||
store.delete_stream(stream_id)
|
store.delete_stream(stream_id)
|
||||||
fire_entity_event("picture_source", "deleted", stream_id)
|
fire_entity_event("picture_source", "deleted", stream_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""User preferences routes — dashboard layout + notification settings.
|
"""User preferences routes — dashboard layout + notification settings + daylight tz.
|
||||||
|
|
||||||
The dashboard layout schema is owned by the frontend (open registry of
|
The dashboard layout schema is owned by the frontend (open registry of
|
||||||
section/cell keys); the backend treats the value as an opaque JSON blob,
|
section/cell keys); the backend treats the value as an opaque JSON blob,
|
||||||
@@ -8,15 +8,26 @@ validates it's a dict with a `version` field, and persists it under the
|
|||||||
Notification preferences are validated server-side via Pydantic so the
|
Notification preferences are validated server-side via Pydantic so the
|
||||||
backend can read them when deciding whether to start the background
|
backend can read them when deciding whether to start the background
|
||||||
discovery watcher.
|
discovery watcher.
|
||||||
|
|
||||||
|
Daylight timezone is a single global IANA tz name shared by every
|
||||||
|
daylight value-source / color-strip-source. Stored as
|
||||||
|
``{"value": "Europe/Berlin"}`` under the ``daylight_timezone`` key, with
|
||||||
|
empty/missing meaning "use system local time".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ledgrab.api.auth import AuthRequired
|
from ledgrab.api.auth import AuthRequired
|
||||||
from ledgrab.api.dependencies import get_database
|
from ledgrab.api.dependencies import get_database
|
||||||
from ledgrab.api.schemas.preferences import NotificationPreferences
|
from ledgrab.api.schemas.preferences import NotificationPreferences
|
||||||
|
from ledgrab.core.processing.daylight_settings import (
|
||||||
|
DAYLIGHT_TIMEZONE_KEY,
|
||||||
|
get_daylight_timezone,
|
||||||
|
set_daylight_timezone,
|
||||||
|
)
|
||||||
from ledgrab.storage.database import Database
|
from ledgrab.storage.database import Database
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
@@ -28,6 +39,12 @@ _DASHBOARD_LAYOUT_KEY = "dashboard_layout"
|
|||||||
_NOTIFICATION_PREFS_KEY = "notification_preferences"
|
_NOTIFICATION_PREFS_KEY = "notification_preferences"
|
||||||
|
|
||||||
|
|
||||||
|
class DaylightTimezonePreference(BaseModel):
|
||||||
|
"""Global IANA timezone applied to every daylight cycle source."""
|
||||||
|
|
||||||
|
timezone: str = Field("", description="IANA timezone name; empty = system local")
|
||||||
|
|
||||||
|
|
||||||
def load_notification_preferences(db: Database | None = None) -> NotificationPreferences:
|
def load_notification_preferences(db: Database | None = None) -> NotificationPreferences:
|
||||||
"""Read notification prefs, returning defaults when unset or corrupt.
|
"""Read notification prefs, returning defaults when unset or corrupt.
|
||||||
|
|
||||||
@@ -144,3 +161,43 @@ async def put_notification_preferences(
|
|||||||
body.channels.model_dump(),
|
body.channels.model_dump(),
|
||||||
)
|
)
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Daylight timezone (global)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/preferences/daylight-timezone",
|
||||||
|
response_model=DaylightTimezonePreference,
|
||||||
|
tags=["Preferences"],
|
||||||
|
)
|
||||||
|
async def get_daylight_timezone_preference(
|
||||||
|
_: AuthRequired,
|
||||||
|
) -> DaylightTimezonePreference:
|
||||||
|
"""Return the global daylight cycle timezone (empty = system local)."""
|
||||||
|
return DaylightTimezonePreference(timezone=get_daylight_timezone())
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/preferences/daylight-timezone",
|
||||||
|
response_model=DaylightTimezonePreference,
|
||||||
|
tags=["Preferences"],
|
||||||
|
)
|
||||||
|
async def put_daylight_timezone_preference(
|
||||||
|
_: AuthRequired,
|
||||||
|
body: DaylightTimezonePreference,
|
||||||
|
) -> DaylightTimezonePreference:
|
||||||
|
"""Persist the global daylight cycle timezone.
|
||||||
|
|
||||||
|
The string is stored verbatim — clients should send a valid IANA name
|
||||||
|
(e.g. ``Europe/Berlin``) or an empty string for "use server local".
|
||||||
|
Daylight streams pick up the new value within ~1 second.
|
||||||
|
"""
|
||||||
|
saved = set_daylight_timezone(body.timezone)
|
||||||
|
logger.info("Daylight timezone updated: %r", saved or "<system local>")
|
||||||
|
return DaylightTimezonePreference(timezone=saved)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router", "DAYLIGHT_TIMEZONE_KEY"]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from ledgrab.api.dependencies import (
|
|||||||
get_color_strip_store,
|
get_color_strip_store,
|
||||||
get_sync_clock_manager,
|
get_sync_clock_manager,
|
||||||
get_sync_clock_store,
|
get_sync_clock_store,
|
||||||
|
get_value_source_store,
|
||||||
)
|
)
|
||||||
from ledgrab.api.schemas.sync_clocks import (
|
from ledgrab.api.schemas.sync_clocks import (
|
||||||
SyncClockCreate,
|
SyncClockCreate,
|
||||||
@@ -18,6 +19,7 @@ from ledgrab.api.schemas.sync_clocks import (
|
|||||||
from ledgrab.storage.sync_clock import SyncClock
|
from ledgrab.storage.sync_clock import SyncClock
|
||||||
from ledgrab.storage.sync_clock_store import SyncClockStore
|
from ledgrab.storage.sync_clock_store import SyncClockStore
|
||||||
from ledgrab.storage.color_strip_store import ColorStripStore
|
from ledgrab.storage.color_strip_store import ColorStripStore
|
||||||
|
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||||
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
from ledgrab.storage.base_store import EntityNotFoundError
|
from ledgrab.storage.base_store import EntityNotFoundError
|
||||||
@@ -137,14 +139,18 @@ async def delete_sync_clock(
|
|||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: SyncClockStore = Depends(get_sync_clock_store),
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
||||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
|
vs_store: ValueSourceStore = Depends(get_value_source_store),
|
||||||
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
||||||
):
|
):
|
||||||
"""Delete a synchronization clock (fails if referenced by CSS sources)."""
|
"""Delete a synchronization clock (fails if referenced by CSS or value sources)."""
|
||||||
try:
|
try:
|
||||||
# Check references
|
# Check references
|
||||||
for source in css_store.get_all_sources():
|
for source in css_store.get_all_sources():
|
||||||
if getattr(source, "clock_id", None) == clock_id:
|
if getattr(source, "clock_id", None) == clock_id:
|
||||||
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
|
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
|
||||||
|
for vs in vs_store.get_all_sources():
|
||||||
|
if getattr(vs, "clock_id", None) == clock_id:
|
||||||
|
raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'")
|
||||||
manager.release_all_for(clock_id)
|
manager.release_all_for(clock_id)
|
||||||
store.delete_clock(clock_id)
|
store.delete_clock(clock_id)
|
||||||
fire_entity_event("sync_clock", "deleted", clock_id)
|
fire_entity_event("sync_clock", "deleted", clock_id)
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ async def list_engines(_auth: AuthRequired):
|
|||||||
type=engine_type,
|
type=engine_type,
|
||||||
name=engine_type.upper(),
|
name=engine_type.upper(),
|
||||||
default_config=engine_class.get_default_config(),
|
default_config=engine_class.get_default_config(),
|
||||||
|
config_choices=engine_class.get_config_choices(),
|
||||||
available=(engine_type in available_set),
|
available=(engine_type in available_set),
|
||||||
has_own_displays=getattr(engine_class, "HAS_OWN_DISPLAYS", False),
|
has_own_displays=getattr(engine_class, "HAS_OWN_DISPLAYS", False),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ _RESPONSE_MAP = {
|
|||||||
speed=s.speed,
|
speed=s.speed,
|
||||||
use_real_time=s.use_real_time,
|
use_real_time=s.use_real_time,
|
||||||
latitude=s.latitude,
|
latitude=s.latitude,
|
||||||
|
longitude=s.longitude,
|
||||||
min_value=s.min_value,
|
min_value=s.min_value,
|
||||||
max_value=s.max_value,
|
max_value=s.max_value,
|
||||||
),
|
),
|
||||||
@@ -127,6 +128,7 @@ _RESPONSE_MAP = {
|
|||||||
colors=[list(c) for c in s.colors],
|
colors=[list(c) for c in s.colors],
|
||||||
speed=s.speed,
|
speed=s.speed,
|
||||||
easing=s.easing,
|
easing=s.easing,
|
||||||
|
clock_id=s.clock_id,
|
||||||
),
|
),
|
||||||
AdaptiveTimeColorValueSource: lambda s: AdaptiveTimeColorValueSourceResponse(
|
AdaptiveTimeColorValueSource: lambda s: AdaptiveTimeColorValueSourceResponse(
|
||||||
id=s.id,
|
id=s.id,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class AnimationConfig(BaseModel):
|
|||||||
"""Procedural animation configuration for static/gradient color strip sources."""
|
"""Procedural animation configuration for static/gradient color strip sources."""
|
||||||
|
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
type: str = "breathing" # breathing | color_cycle | gradient_shift | wave
|
type: str = "breathing" # breathing | gradient_shift | wave
|
||||||
speed: float = Field(1.0, ge=0.1, le=10.0, description="Speed multiplier (0.1-10.0)")
|
speed: float = Field(1.0, ge=0.1, le=10.0, description="Speed multiplier (0.1-10.0)")
|
||||||
|
|
||||||
|
|
||||||
@@ -126,11 +126,6 @@ class GradientCSSResponse(_CSSResponseBase):
|
|||||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
|
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
|
||||||
|
|
||||||
|
|
||||||
class ColorCycleCSSResponse(_CSSResponseBase):
|
|
||||||
source_type: Literal["color_cycle"] = "color_cycle"
|
|
||||||
colors: List[List[int]] = Field(description="List of [R,G,B] colors to cycle")
|
|
||||||
|
|
||||||
|
|
||||||
class EffectCSSResponse(_CSSResponseBase):
|
class EffectCSSResponse(_CSSResponseBase):
|
||||||
source_type: Literal["effect"] = "effect"
|
source_type: Literal["effect"] = "effect"
|
||||||
effect_type: str = Field(description="Effect algorithm")
|
effect_type: str = Field(description="Effect algorithm")
|
||||||
@@ -241,7 +236,6 @@ ColorStripSourceResponse = Annotated[
|
|||||||
Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")],
|
Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")],
|
||||||
Annotated[StaticCSSResponse, Tag("static")],
|
Annotated[StaticCSSResponse, Tag("static")],
|
||||||
Annotated[GradientCSSResponse, Tag("gradient")],
|
Annotated[GradientCSSResponse, Tag("gradient")],
|
||||||
Annotated[ColorCycleCSSResponse, Tag("color_cycle")],
|
|
||||||
Annotated[EffectCSSResponse, Tag("effect")],
|
Annotated[EffectCSSResponse, Tag("effect")],
|
||||||
Annotated[CompositeCSSResponse, Tag("composite")],
|
Annotated[CompositeCSSResponse, Tag("composite")],
|
||||||
Annotated[MappedCSSResponse, Tag("mapped")],
|
Annotated[MappedCSSResponse, Tag("mapped")],
|
||||||
@@ -303,11 +297,6 @@ class GradientCSSCreate(_CSSCreateBase):
|
|||||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
|
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
|
||||||
|
|
||||||
|
|
||||||
class ColorCycleCSSCreate(_CSSCreateBase):
|
|
||||||
source_type: Literal["color_cycle"] = "color_cycle"
|
|
||||||
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle")
|
|
||||||
|
|
||||||
|
|
||||||
class EffectCSSCreate(_CSSCreateBase):
|
class EffectCSSCreate(_CSSCreateBase):
|
||||||
source_type: Literal["effect"] = "effect"
|
source_type: Literal["effect"] = "effect"
|
||||||
effect_type: Optional[str] = Field(None, description="Effect algorithm")
|
effect_type: Optional[str] = Field(None, description="Effect algorithm")
|
||||||
@@ -431,7 +420,6 @@ ColorStripSourceCreate = Annotated[
|
|||||||
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")],
|
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")],
|
||||||
Annotated[StaticCSSCreate, Tag("static")],
|
Annotated[StaticCSSCreate, Tag("static")],
|
||||||
Annotated[GradientCSSCreate, Tag("gradient")],
|
Annotated[GradientCSSCreate, Tag("gradient")],
|
||||||
Annotated[ColorCycleCSSCreate, Tag("color_cycle")],
|
|
||||||
Annotated[EffectCSSCreate, Tag("effect")],
|
Annotated[EffectCSSCreate, Tag("effect")],
|
||||||
Annotated[CompositeCSSCreate, Tag("composite")],
|
Annotated[CompositeCSSCreate, Tag("composite")],
|
||||||
Annotated[MappedCSSCreate, Tag("mapped")],
|
Annotated[MappedCSSCreate, Tag("mapped")],
|
||||||
@@ -493,11 +481,6 @@ class GradientCSSUpdate(_CSSUpdateBase):
|
|||||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
|
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
|
||||||
|
|
||||||
|
|
||||||
class ColorCycleCSSUpdate(_CSSUpdateBase):
|
|
||||||
source_type: Literal["color_cycle"] = "color_cycle"
|
|
||||||
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle")
|
|
||||||
|
|
||||||
|
|
||||||
class EffectCSSUpdate(_CSSUpdateBase):
|
class EffectCSSUpdate(_CSSUpdateBase):
|
||||||
source_type: Literal["effect"] = "effect"
|
source_type: Literal["effect"] = "effect"
|
||||||
effect_type: Optional[str] = Field(None, description="Effect algorithm")
|
effect_type: Optional[str] = Field(None, description="Effect algorithm")
|
||||||
@@ -619,7 +602,6 @@ ColorStripSourceUpdate = Annotated[
|
|||||||
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")],
|
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")],
|
||||||
Annotated[StaticCSSUpdate, Tag("static")],
|
Annotated[StaticCSSUpdate, Tag("static")],
|
||||||
Annotated[GradientCSSUpdate, Tag("gradient")],
|
Annotated[GradientCSSUpdate, Tag("gradient")],
|
||||||
Annotated[ColorCycleCSSUpdate, Tag("color_cycle")],
|
|
||||||
Annotated[EffectCSSUpdate, Tag("effect")],
|
Annotated[EffectCSSUpdate, Tag("effect")],
|
||||||
Annotated[CompositeCSSUpdate, Tag("composite")],
|
Annotated[CompositeCSSUpdate, Tag("composite")],
|
||||||
Annotated[MappedCSSUpdate, Tag("mapped")],
|
Annotated[MappedCSSUpdate, Tag("mapped")],
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ class HomeAssistantConnectionStatus(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
connected: bool
|
connected: bool
|
||||||
entity_count: int
|
entity_count: int
|
||||||
|
host: str = ""
|
||||||
|
|
||||||
|
|
||||||
class HomeAssistantStatusResponse(BaseModel):
|
class HomeAssistantStatusResponse(BaseModel):
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class EngineInfo(BaseModel):
|
|||||||
type: str = Field(description="Engine type identifier (e.g., 'mss', 'dxcam')")
|
type: str = Field(description="Engine type identifier (e.g., 'mss', 'dxcam')")
|
||||||
name: str = Field(description="Human-readable engine name")
|
name: str = Field(description="Human-readable engine name")
|
||||||
default_config: Dict = Field(description="Default configuration for this engine")
|
default_config: Dict = Field(description="Default configuration for this engine")
|
||||||
|
config_choices: Dict[str, List[str]] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Allowed values for enum-like config keys on this platform",
|
||||||
|
)
|
||||||
available: bool = Field(description="Whether engine is available on this system")
|
available: bool = Field(description="Whether engine is available on this system")
|
||||||
has_own_displays: bool = Field(
|
has_own_displays: bool = Field(
|
||||||
default=False, description="Engine has its own device list (not desktop monitors)"
|
default=False, description="Engine has its own device list (not desktop monitors)"
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class DaylightValueSourceResponse(_ValueSourceResponseBase):
|
|||||||
speed: float = Field(description="Simulation speed multiplier")
|
speed: float = Field(description="Simulation speed multiplier")
|
||||||
use_real_time: bool = Field(description="Use wall-clock time")
|
use_real_time: bool = Field(description="Use wall-clock time")
|
||||||
latitude: float = Field(description="Geographic latitude")
|
latitude: float = Field(description="Geographic latitude")
|
||||||
|
longitude: float = Field(description="Geographic longitude")
|
||||||
min_value: float = Field(description="Minimum output")
|
min_value: float = Field(description="Minimum output")
|
||||||
max_value: float = Field(description="Maximum output")
|
max_value: float = Field(description="Maximum output")
|
||||||
|
|
||||||
@@ -87,8 +88,11 @@ class AnimatedColorValueSourceResponse(_ValueSourceResponseBase):
|
|||||||
source_type: Literal["animated_color"] = "animated_color"
|
source_type: Literal["animated_color"] = "animated_color"
|
||||||
return_type: Literal["color"] = "color"
|
return_type: Literal["color"] = "color"
|
||||||
colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]")
|
colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]")
|
||||||
speed: float = Field(description="Cycles per minute")
|
speed: float = Field(description="Cycles per minute (ignored when clock_id is set)")
|
||||||
easing: str = Field(description="Color easing: linear|step")
|
easing: str = Field(description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine")
|
||||||
|
clock_id: Optional[str] = Field(
|
||||||
|
None, description="Optional sync clock ID for shared timing (overrides speed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveTimeColorValueSourceResponse(_ValueSourceResponseBase):
|
class AdaptiveTimeColorValueSourceResponse(_ValueSourceResponseBase):
|
||||||
@@ -215,6 +219,9 @@ class DaylightValueSourceCreate(_ValueSourceCreateBase):
|
|||||||
speed: float = Field(1.0, description="Simulation speed multiplier", ge=0.1, le=120.0)
|
speed: float = Field(1.0, description="Simulation speed multiplier", ge=0.1, le=120.0)
|
||||||
use_real_time: bool = Field(False, description="Use wall-clock time instead of simulation")
|
use_real_time: bool = Field(False, description="Use wall-clock time instead of simulation")
|
||||||
latitude: float = Field(50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
|
latitude: float = Field(50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
|
||||||
|
longitude: float = Field(
|
||||||
|
0.0, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0
|
||||||
|
)
|
||||||
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
|
|
||||||
@@ -234,7 +241,12 @@ class AnimatedColorValueSourceCreate(_ValueSourceCreateBase):
|
|||||||
description="Color list [[R,G,B], ...]",
|
description="Color list [[R,G,B], ...]",
|
||||||
)
|
)
|
||||||
speed: float = Field(10.0, description="Cycles per minute", ge=0.1, le=120.0)
|
speed: float = Field(10.0, description="Cycles per minute", ge=0.1, le=120.0)
|
||||||
easing: str = Field("linear", description="Color easing: linear|step")
|
easing: str = Field(
|
||||||
|
"linear", description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
|
||||||
|
)
|
||||||
|
clock_id: Optional[str] = Field(
|
||||||
|
None, description="Optional sync clock ID (overrides speed when set)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveTimeColorValueSourceCreate(_ValueSourceCreateBase):
|
class AdaptiveTimeColorValueSourceCreate(_ValueSourceCreateBase):
|
||||||
@@ -356,6 +368,9 @@ class DaylightValueSourceUpdate(_ValueSourceUpdateBase):
|
|||||||
speed: Optional[float] = Field(None, description="Simulation speed", ge=0.1, le=120.0)
|
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")
|
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)
|
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)
|
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)
|
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
|
||||||
|
|
||||||
@@ -369,7 +384,12 @@ class AnimatedColorValueSourceUpdate(_ValueSourceUpdateBase):
|
|||||||
source_type: Literal["animated_color"] = "animated_color"
|
source_type: Literal["animated_color"] = "animated_color"
|
||||||
colors: Optional[List[List[int]]] = Field(None, description="Color list [[R,G,B], ...]")
|
colors: 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)
|
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
|
||||||
easing: Optional[str] = Field(None, description="Color easing: linear|step")
|
easing: Optional[str] = Field(
|
||||||
|
None, description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
|
||||||
|
)
|
||||||
|
clock_id: Optional[str] = Field(
|
||||||
|
None, description="Optional sync clock ID (empty string clears, null leaves unchanged)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase):
|
class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase):
|
||||||
|
|||||||
@@ -117,6 +117,16 @@ class CaptureEngine(ABC):
|
|||||||
"""Get default configuration for this engine."""
|
"""Get default configuration for this engine."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_config_choices(cls) -> Dict[str, List[str]]:
|
||||||
|
"""Return allowed values for enum-like config keys on this platform.
|
||||||
|
|
||||||
|
Keys returned here narrow the values the UI offers for the
|
||||||
|
corresponding config field. Engines that have no platform-specific
|
||||||
|
constraints can leave this empty (default).
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_available_displays(cls) -> List[DisplayInfo]:
|
def get_available_displays(cls) -> List[DisplayInfo]:
|
||||||
|
|||||||
@@ -8,12 +8,19 @@ Prerequisites (optional dependency):
|
|||||||
pip install opencv-python-headless>=4.8.0
|
pip install opencv-python-headless>=4.8.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import platform
|
import platform
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, List, Optional, Set
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
# OpenCV's MSMF backend on Windows often fails to open the device
|
||||||
|
# ("cap.isOpened() == False" right after VideoCapture returns) when
|
||||||
|
# hardware MFTs are enabled. Disabling them is the documented mitigation.
|
||||||
|
# Set before any cv2 import so the MSMF backend picks it up on first use.
|
||||||
|
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||||
|
|
||||||
|
|
||||||
from ledgrab.core.capture_engines.base import (
|
from ledgrab.core.capture_engines.base import (
|
||||||
CaptureEngine,
|
CaptureEngine,
|
||||||
@@ -27,6 +34,41 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
_MAX_CAMERA_INDEX = 10 # probe indices 0..9
|
_MAX_CAMERA_INDEX = 10 # probe indices 0..9
|
||||||
|
|
||||||
|
# Sentinel used to ask DShow/MSMF/V4L2 for the highest mode the device supports.
|
||||||
|
# OpenCV will clamp the requested width/height down to the nearest supported mode.
|
||||||
|
_PROBE_MAX_DIM = 9999
|
||||||
|
|
||||||
|
# Resolution presets shown in the UI. "auto" means: open at the camera's max
|
||||||
|
# (probed via _PROBE_MAX_DIM); the other entries are explicit overrides.
|
||||||
|
_RESOLUTION_CHOICES: List[str] = [
|
||||||
|
"auto",
|
||||||
|
"640x480",
|
||||||
|
"1280x720",
|
||||||
|
"1920x1080",
|
||||||
|
"2560x1440",
|
||||||
|
"3840x2160",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_resolution(value: Any) -> Optional[tuple[int, int]]:
|
||||||
|
"""Parse a 'WxH' string into (width, height). Returns None for 'auto' or invalid."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
s = value.strip().lower()
|
||||||
|
if s in ("", "auto"):
|
||||||
|
return None
|
||||||
|
parts = s.replace("×", "x").split("x")
|
||||||
|
if len(parts) != 2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
w, h = int(parts[0]), int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
return None
|
||||||
|
return w, h
|
||||||
|
|
||||||
|
|
||||||
# Process-wide registry of cv2 camera indices currently held open.
|
# Process-wide registry of cv2 camera indices currently held open.
|
||||||
# Prevents _enumerate_cameras from probing an in-use camera (which can
|
# Prevents _enumerate_cameras from probing an in-use camera (which can
|
||||||
# crash the DSHOW backend on Windows) and prevents two CameraCaptureStreams
|
# crash the DSHOW backend on Windows) and prevents two CameraCaptureStreams
|
||||||
@@ -48,6 +90,85 @@ def _get_default_backend():
|
|||||||
return "auto"
|
return "auto"
|
||||||
|
|
||||||
|
|
||||||
|
# Maps our backend ids to the label cv2.getBuildInformation() prints in the
|
||||||
|
# Video I/O section. Entries missing or marked "NO" mean the installed
|
||||||
|
# opencv wheel was compiled without that backend — even if cv2's registry
|
||||||
|
# still lists it, attempts to open will fail with isOpened()==False.
|
||||||
|
_BUILDINFO_LABELS: Dict[str, str] = {
|
||||||
|
"dshow": "DirectShow",
|
||||||
|
"msmf": "Media Foundation",
|
||||||
|
"v4l2": "v4l/v4l2",
|
||||||
|
"avfoundation": "AVFoundation",
|
||||||
|
}
|
||||||
|
|
||||||
|
_compiled_backends_cache: Optional[Set[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_compiled_backends() -> Set[str]:
|
||||||
|
"""Return the set of backend ids the installed cv2 was compiled with.
|
||||||
|
|
||||||
|
Parses ``cv2.getBuildInformation()`` because cv2's videoio registry can
|
||||||
|
advertise backends that aren't actually functional (e.g. wheels that
|
||||||
|
omit Media Foundation still list MSMF in the registry).
|
||||||
|
"""
|
||||||
|
global _compiled_backends_cache
|
||||||
|
if _compiled_backends_cache is not None:
|
||||||
|
return _compiled_backends_cache
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
except ImportError:
|
||||||
|
_compiled_backends_cache = set()
|
||||||
|
return _compiled_backends_cache
|
||||||
|
|
||||||
|
info = cv2.getBuildInformation()
|
||||||
|
# Restrict the search to the "Video I/O" section so labels like
|
||||||
|
# "Media Foundation" don't pick up unrelated mentions elsewhere.
|
||||||
|
start = info.find("Video I/O:")
|
||||||
|
section = info[start:] if start != -1 else info
|
||||||
|
end_markers = ("Parallel framework", "Trace:", "Other third-party libraries")
|
||||||
|
for marker in end_markers:
|
||||||
|
idx = section.find(marker)
|
||||||
|
if idx != -1:
|
||||||
|
section = section[:idx]
|
||||||
|
break
|
||||||
|
|
||||||
|
found: Set[str] = set()
|
||||||
|
for backend, label in _BUILDINFO_LABELS.items():
|
||||||
|
# Match "<label>: <whitespace>YES" anywhere in the section.
|
||||||
|
needle = label + ":"
|
||||||
|
pos = section.find(needle)
|
||||||
|
if pos == -1:
|
||||||
|
continue
|
||||||
|
line_end = section.find("\n", pos)
|
||||||
|
line = section[pos : line_end if line_end != -1 else len(section)]
|
||||||
|
if "YES" in line.upper():
|
||||||
|
found.add(backend)
|
||||||
|
|
||||||
|
_compiled_backends_cache = found
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _get_supported_backends() -> List[str]:
|
||||||
|
"""Return the list of cv2 backends that make sense on this platform.
|
||||||
|
|
||||||
|
Only advertises backends that are both (a) appropriate for the host OS
|
||||||
|
and (b) actually compiled into the installed opencv wheel. ``auto`` is
|
||||||
|
always offered as a safe default.
|
||||||
|
"""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
candidates = ["dshow", "msmf"]
|
||||||
|
elif sys.platform.startswith("linux"):
|
||||||
|
candidates = ["v4l2"]
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
candidates = ["avfoundation"]
|
||||||
|
else:
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
compiled = _get_compiled_backends()
|
||||||
|
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) -> Optional[int]:
|
||||||
"""Convert a backend name string to cv2 API preference constant."""
|
"""Convert a backend name string to cv2 API preference constant."""
|
||||||
return _CV2_BACKENDS.get(backend_name)
|
return _CV2_BACKENDS.get(backend_name)
|
||||||
@@ -256,8 +377,20 @@ def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]:
|
|||||||
cap.release()
|
cap.release()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
# Probe the camera's max supported mode by asking for an absurdly large
|
||||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
# frame size — DShow/MSMF/V4L2 clamp down to the highest available mode.
|
||||||
|
# If the probe is rejected (rare driver issue), fall back to the default
|
||||||
|
# mode that the camera reports immediately after open.
|
||||||
|
default_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
default_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
try:
|
||||||
|
cap.set(cv2.CAP_PROP_FRAME_WIDTH, _PROBE_MAX_DIM)
|
||||||
|
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, _PROBE_MAX_DIM)
|
||||||
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or default_width
|
||||||
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or default_height
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Camera {i} max-resolution probe failed: {e}")
|
||||||
|
width, height = default_width, default_height
|
||||||
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||||
|
|
||||||
name = friendly_names.get(sequential_idx, f"Camera {sequential_idx}")
|
name = friendly_names.get(sequential_idx, f"Camera {sequential_idx}")
|
||||||
@@ -328,16 +461,28 @@ class CameraCaptureStream(CaptureStream):
|
|||||||
_active_cv2_indices.add(cv2_index)
|
_active_cv2_indices.add(cv2_index)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Open the camera
|
# Open the camera. MSMF's first open after a DShow session (or its
|
||||||
|
# very first cold open in the process) is timing-sensitive on
|
||||||
|
# Windows, so retry briefly before giving up.
|
||||||
backend_id = _cv2_backend_id(backend_name)
|
backend_id = _cv2_backend_id(backend_name)
|
||||||
if backend_id is not None:
|
attempts = 3 if backend_name == "msmf" else 1
|
||||||
self._cap = cv2.VideoCapture(cv2_index, backend_id)
|
self._cap = None
|
||||||
else:
|
for attempt in range(attempts):
|
||||||
self._cap = cv2.VideoCapture(cv2_index)
|
if backend_id is not None:
|
||||||
|
cap = cv2.VideoCapture(cv2_index, backend_id)
|
||||||
|
else:
|
||||||
|
cap = cv2.VideoCapture(cv2_index)
|
||||||
|
if cap.isOpened():
|
||||||
|
self._cap = cap
|
||||||
|
break
|
||||||
|
cap.release()
|
||||||
|
if attempt + 1 < attempts:
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
if not self._cap.isOpened():
|
if self._cap is None or not self._cap.isOpened():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Failed to open camera {self.display_index} " f"(cv2 index {cv2_index})"
|
f"Failed to open camera {self.display_index} "
|
||||||
|
f"(cv2 index {cv2_index}, backend={backend_name})"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
with _camera_lock:
|
with _camera_lock:
|
||||||
@@ -346,12 +491,28 @@ class CameraCaptureStream(CaptureStream):
|
|||||||
|
|
||||||
self._cv2_index = cv2_index
|
self._cv2_index = cv2_index
|
||||||
|
|
||||||
# Apply optional resolution override
|
# Resolve effective resolution.
|
||||||
res_w = self.config.get("resolution_width", 0)
|
# Priority: legacy `resolution_width`/`resolution_height` (if both > 0)
|
||||||
res_h = self.config.get("resolution_height", 0)
|
# → new `resolution` enum string (e.g. "1920x1080" or "auto")
|
||||||
if res_w > 0 and res_h > 0:
|
# → "auto" (open at the camera's max).
|
||||||
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, res_w)
|
# On Windows DShow/MSMF the default opening mode is typically 640x480
|
||||||
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, res_h)
|
# regardless of the camera's hardware ceiling, so when no explicit
|
||||||
|
# override is given we ask for the highest mode the device supports
|
||||||
|
# by setting an absurdly large frame size — drivers clamp down to the
|
||||||
|
# nearest supported mode.
|
||||||
|
legacy_w = self.config.get("resolution_width", 0) or 0
|
||||||
|
legacy_h = self.config.get("resolution_height", 0) or 0
|
||||||
|
if legacy_w > 0 and legacy_h > 0:
|
||||||
|
target_w, target_h = legacy_w, legacy_h
|
||||||
|
else:
|
||||||
|
parsed = _parse_resolution(self.config.get("resolution", "auto"))
|
||||||
|
if parsed is not None:
|
||||||
|
target_w, target_h = parsed
|
||||||
|
else:
|
||||||
|
target_w, target_h = _PROBE_MAX_DIM, _PROBE_MAX_DIM
|
||||||
|
|
||||||
|
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, target_w)
|
||||||
|
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, target_h)
|
||||||
|
|
||||||
# Test read
|
# Test read
|
||||||
ret, frame = self._cap.read()
|
ret, frame = self._cap.read()
|
||||||
@@ -434,10 +595,20 @@ class CameraEngine(CaptureEngine):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_default_config(cls) -> Dict[str, Any]:
|
def get_default_config(cls) -> Dict[str, Any]:
|
||||||
|
# `resolution` is the user-facing control. Legacy numeric overrides
|
||||||
|
# `resolution_width`/`resolution_height` are still honored if present
|
||||||
|
# in stored configs (see CameraCaptureStream.initialize), but are no
|
||||||
|
# longer surfaced in the default config — the dropdown replaces them.
|
||||||
return {
|
return {
|
||||||
"camera_backend": _get_default_backend(),
|
"camera_backend": _get_default_backend(),
|
||||||
"resolution_width": 0,
|
"resolution": "auto",
|
||||||
"resolution_height": 0,
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_config_choices(cls) -> Dict[str, List[str]]:
|
||||||
|
return {
|
||||||
|
"camera_backend": _get_supported_backends(),
|
||||||
|
"resolution": list(_RESOLUTION_CHOICES),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ _PS_IDS = {
|
|||||||
|
|
||||||
_CSS_IDS = {
|
_CSS_IDS = {
|
||||||
"gradient": "css_demo0001",
|
"gradient": "css_demo0001",
|
||||||
"cycle": "css_demo0002",
|
|
||||||
"picture": "css_demo0003",
|
"picture": "css_demo0003",
|
||||||
"audio": "css_demo0004",
|
"audio": "css_demo0004",
|
||||||
}
|
}
|
||||||
@@ -267,22 +266,6 @@ def _build_color_strip_sources() -> dict:
|
|||||||
"created_at": _NOW,
|
"created_at": _NOW,
|
||||||
"updated_at": _NOW,
|
"updated_at": _NOW,
|
||||||
},
|
},
|
||||||
_CSS_IDS["cycle"]: {
|
|
||||||
"id": _CSS_IDS["cycle"],
|
|
||||||
"name": "Warm Color Cycle",
|
|
||||||
"source_type": "color_cycle",
|
|
||||||
"description": "Smoothly cycles through warm colors",
|
|
||||||
"clock_id": None,
|
|
||||||
"tags": ["demo"],
|
|
||||||
"colors": [
|
|
||||||
[255, 60, 0],
|
|
||||||
[255, 140, 0],
|
|
||||||
[255, 200, 50],
|
|
||||||
[255, 100, 20],
|
|
||||||
],
|
|
||||||
"created_at": _NOW,
|
|
||||||
"updated_at": _NOW,
|
|
||||||
},
|
|
||||||
_CSS_IDS["picture"]: {
|
_CSS_IDS["picture"]: {
|
||||||
"id": _CSS_IDS["picture"],
|
"id": _CSS_IDS["picture"],
|
||||||
"name": "Screen Capture — Main Display",
|
"name": "Screen Capture — Main Display",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ via the shim module ``color_strip_stream.py``.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .base import ColorStripStream, _SimpleNoise1D, _gradient_noise
|
from .base import ColorStripStream, _SimpleNoise1D, _gradient_noise
|
||||||
from .cycle import ColorCycleColorStripStream
|
|
||||||
from .gradient import GradientColorStripStream
|
from .gradient import GradientColorStripStream
|
||||||
from .helpers import _compute_gradient_colors
|
from .helpers import _compute_gradient_colors
|
||||||
from .picture import PictureColorStripStream
|
from .picture import PictureColorStripStream
|
||||||
@@ -16,7 +15,6 @@ __all__ = [
|
|||||||
"ColorStripStream",
|
"ColorStripStream",
|
||||||
"PictureColorStripStream",
|
"PictureColorStripStream",
|
||||||
"StaticColorStripStream",
|
"StaticColorStripStream",
|
||||||
"ColorCycleColorStripStream",
|
|
||||||
"GradientColorStripStream",
|
"GradientColorStripStream",
|
||||||
"_compute_gradient_colors",
|
"_compute_gradient_colors",
|
||||||
"_SimpleNoise1D",
|
"_SimpleNoise1D",
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
"""Color cycle stream — smoothly cycles through user-defined colors."""
|
|
||||||
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from ledgrab.utils import get_logger
|
|
||||||
from ledgrab.utils.frame_limiter import FrameLimiter
|
|
||||||
from ledgrab.utils.timer import high_resolution_timer
|
|
||||||
|
|
||||||
from .base import ColorStripStream
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ColorCycleColorStripStream(ColorStripStream):
|
|
||||||
"""Color strip stream that smoothly cycles through a user-defined color list.
|
|
||||||
|
|
||||||
All LEDs receive the same solid color at any moment, continuously interpolating
|
|
||||||
between the configured colors in a loop.
|
|
||||||
|
|
||||||
LED count auto-sizes from the connected device when led_count == 0 in
|
|
||||||
the source config; configure(device_led_count) is called by
|
|
||||||
WledTargetProcessor on start.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, source):
|
|
||||||
self._colors_lock = threading.Lock()
|
|
||||||
self._running = False
|
|
||||||
self._thread: Optional[threading.Thread] = None
|
|
||||||
self._fps = 30
|
|
||||||
self._frame_time = 1.0 / 30
|
|
||||||
self._clock = None # optional SyncClockRuntime
|
|
||||||
self._update_from_source(source)
|
|
||||||
|
|
||||||
def _update_from_source(self, source) -> None:
|
|
||||||
raw = source.colors if isinstance(source.colors, list) else []
|
|
||||||
default = [
|
|
||||||
[255, 0, 0],
|
|
||||||
[255, 255, 0],
|
|
||||||
[0, 255, 0],
|
|
||||||
[0, 255, 255],
|
|
||||||
[0, 0, 255],
|
|
||||||
[255, 0, 255],
|
|
||||||
]
|
|
||||||
self._color_list = [c for c in raw if isinstance(c, list) and len(c) == 3] or default
|
|
||||||
_lc = getattr(source, "led_count", 0)
|
|
||||||
self._auto_size = not _lc
|
|
||||||
self._led_count = _lc if _lc > 0 else 1
|
|
||||||
self._rebuild_colors()
|
|
||||||
|
|
||||||
def _rebuild_colors(self) -> None:
|
|
||||||
pixel = np.array(self._color_list[0], dtype=np.uint8)
|
|
||||||
colors = np.tile(pixel, (self._led_count, 1))
|
|
||||||
with self._colors_lock:
|
|
||||||
self._colors = colors
|
|
||||||
|
|
||||||
def configure(self, device_led_count: int) -> None:
|
|
||||||
"""Size to device LED count when led_count was 0 (auto-size)."""
|
|
||||||
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
|
|
||||||
self._led_count = device_led_count
|
|
||||||
self._rebuild_colors()
|
|
||||||
logger.debug(f"ColorCycleColorStripStream auto-sized to {device_led_count} LEDs")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def target_fps(self) -> int:
|
|
||||||
return self._fps
|
|
||||||
|
|
||||||
@property
|
|
||||||
def led_count(self) -> int:
|
|
||||||
return self._led_count
|
|
||||||
|
|
||||||
def set_capture_fps(self, fps: int) -> None:
|
|
||||||
"""Update animation loop rate. Thread-safe (read atomically by the loop)."""
|
|
||||||
fps = max(1, min(90, fps))
|
|
||||||
self._fps = fps
|
|
||||||
self._frame_time = 1.0 / fps
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
if self._running:
|
|
||||||
return
|
|
||||||
self._running = True
|
|
||||||
self._thread = threading.Thread(
|
|
||||||
target=self._animate_loop,
|
|
||||||
name="css-color-cycle",
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
self._thread.start()
|
|
||||||
logger.info(
|
|
||||||
f"ColorCycleColorStripStream started (leds={self._led_count}, colors={len(self._color_list)})"
|
|
||||||
)
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
self._running = False
|
|
||||||
if self._thread:
|
|
||||||
self._thread.join(timeout=5.0)
|
|
||||||
if self._thread.is_alive():
|
|
||||||
logger.warning(
|
|
||||||
"ColorCycleColorStripStream animate thread did not terminate within 5s"
|
|
||||||
)
|
|
||||||
self._thread = None
|
|
||||||
logger.info("ColorCycleColorStripStream stopped")
|
|
||||||
|
|
||||||
def get_latest_colors(self) -> Optional[np.ndarray]:
|
|
||||||
with self._colors_lock:
|
|
||||||
return self._colors
|
|
||||||
|
|
||||||
def update_source(self, source) -> None:
|
|
||||||
from ledgrab.storage.color_strip_source import ColorCycleColorStripSource
|
|
||||||
|
|
||||||
if isinstance(source, ColorCycleColorStripSource):
|
|
||||||
prev_led_count = self._led_count if self._auto_size else None
|
|
||||||
self._update_from_source(source)
|
|
||||||
if prev_led_count and self._auto_size:
|
|
||||||
self._led_count = prev_led_count
|
|
||||||
self._rebuild_colors()
|
|
||||||
logger.info("ColorCycleColorStripStream params updated in-place")
|
|
||||||
|
|
||||||
def set_clock(self, clock) -> None:
|
|
||||||
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
|
|
||||||
self._clock = clock
|
|
||||||
|
|
||||||
def _animate_loop(self) -> None:
|
|
||||||
"""Background thread: interpolate between colors at target fps.
|
|
||||||
|
|
||||||
Uses double-buffered output arrays to avoid per-frame allocations.
|
|
||||||
"""
|
|
||||||
_pool_n = 0
|
|
||||||
_buf_a = _buf_b = None
|
|
||||||
_use_a = True
|
|
||||||
|
|
||||||
limiter = FrameLimiter(self._fps)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with high_resolution_timer():
|
|
||||||
while self._running:
|
|
||||||
limiter.begin()
|
|
||||||
wall_start = time.perf_counter()
|
|
||||||
frame_time = self._frame_time
|
|
||||||
try:
|
|
||||||
color_list = self._color_list
|
|
||||||
clock = self._clock
|
|
||||||
if clock:
|
|
||||||
if not clock.is_running:
|
|
||||||
time.sleep(0.1)
|
|
||||||
continue
|
|
||||||
speed = clock.speed
|
|
||||||
t = clock.get_time()
|
|
||||||
else:
|
|
||||||
speed = 1.0
|
|
||||||
t = wall_start
|
|
||||||
n = self._led_count
|
|
||||||
num = len(color_list)
|
|
||||||
if num >= 2:
|
|
||||||
if n != _pool_n:
|
|
||||||
_pool_n = n
|
|
||||||
_buf_a = np.empty((n, 3), dtype=np.uint8)
|
|
||||||
_buf_b = np.empty((n, 3), dtype=np.uint8)
|
|
||||||
|
|
||||||
buf = _buf_a if _use_a else _buf_b
|
|
||||||
_use_a = not _use_a
|
|
||||||
|
|
||||||
# 0.05 factor → one full cycle every 20s at speed=1.0
|
|
||||||
cycle_pos = (speed * t * 0.05) % 1.0
|
|
||||||
seg = cycle_pos * num
|
|
||||||
idx = int(seg) % num
|
|
||||||
t_i = seg - int(seg)
|
|
||||||
c1 = color_list[idx]
|
|
||||||
c2 = color_list[(idx + 1) % num]
|
|
||||||
buf[:] = (
|
|
||||||
min(255, int(c1[0] + (c2[0] - c1[0]) * t_i)),
|
|
||||||
min(255, int(c1[1] + (c2[1] - c1[1]) * t_i)),
|
|
||||||
min(255, int(c1[2] + (c2[2] - c1[2]) * t_i)),
|
|
||||||
)
|
|
||||||
with self._colors_lock:
|
|
||||||
self._colors = buf
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"ColorCycleColorStripStream animation error: {e}")
|
|
||||||
limiter.wait(frame_time)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Fatal ColorCycleColorStripStream loop error: {e}", exc_info=True)
|
|
||||||
finally:
|
|
||||||
self._running = False
|
|
||||||
@@ -73,7 +73,14 @@ class StaticColorStripStream(ColorStripStream):
|
|||||||
@property
|
@property
|
||||||
def is_animated(self) -> bool:
|
def is_animated(self) -> bool:
|
||||||
anim = self._animation
|
anim = self._animation
|
||||||
return bool(anim and anim.get("enabled"))
|
if anim and anim.get("enabled"):
|
||||||
|
return True
|
||||||
|
return self._is_color_bound()
|
||||||
|
|
||||||
|
def _is_color_bound(self) -> bool:
|
||||||
|
"""True when the `color` property is driven by a ValueStream."""
|
||||||
|
vs = self._value_streams
|
||||||
|
return bool(vs and vs.get("color"))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def led_count(self) -> int:
|
def led_count(self) -> int:
|
||||||
@@ -243,10 +250,28 @@ class StaticColorStripStream(ColorStripStream):
|
|||||||
if colors is not None:
|
if colors is not None:
|
||||||
with self._colors_lock:
|
with self._colors_lock:
|
||||||
self._colors = colors
|
self._colors = colors
|
||||||
|
elif self._is_color_bound():
|
||||||
|
# No animation, but color is driven by a ValueStream —
|
||||||
|
# poll and forward live color updates so the bound
|
||||||
|
# source is honoured (otherwise LEDs stay stuck on
|
||||||
|
# the static fallback).
|
||||||
|
n = self._led_count
|
||||||
|
if n != _pool_n:
|
||||||
|
_pool_n = n
|
||||||
|
_buf_a = np.empty((n, 3), dtype=np.uint8)
|
||||||
|
_buf_b = np.empty((n, 3), dtype=np.uint8)
|
||||||
|
buf = _buf_a if _use_a else _buf_b
|
||||||
|
_use_a = not _use_a
|
||||||
|
buf[:] = self.resolve_color("color", self._source_color)
|
||||||
|
with self._colors_lock:
|
||||||
|
self._colors = buf
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"StaticColorStripStream animation error: {e}")
|
logger.error(f"StaticColorStripStream animation error: {e}")
|
||||||
|
|
||||||
sleep_target = frame_time if anim and anim.get("enabled") else 0.25
|
if (anim and anim.get("enabled")) or self._is_color_bound():
|
||||||
|
sleep_target = frame_time
|
||||||
|
else:
|
||||||
|
sleep_target = 0.25
|
||||||
limiter.wait(sleep_target)
|
limiter.wait(sleep_target)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Fatal StaticColorStripStream loop error: {e}", exc_info=True)
|
logger.error(f"Fatal StaticColorStripStream loop error: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import path ``from ledgrab.core.processing.color_strip_stream import X``.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from ledgrab.core.processing.color_strip import ( # noqa: F401
|
from ledgrab.core.processing.color_strip import ( # noqa: F401
|
||||||
ColorCycleColorStripStream,
|
|
||||||
ColorStripStream,
|
ColorStripStream,
|
||||||
GradientColorStripStream,
|
GradientColorStripStream,
|
||||||
PictureColorStripStream,
|
PictureColorStripStream,
|
||||||
@@ -20,7 +19,6 @@ __all__ = [
|
|||||||
"ColorStripStream",
|
"ColorStripStream",
|
||||||
"PictureColorStripStream",
|
"PictureColorStripStream",
|
||||||
"StaticColorStripStream",
|
"StaticColorStripStream",
|
||||||
"ColorCycleColorStripStream",
|
|
||||||
"GradientColorStripStream",
|
"GradientColorStripStream",
|
||||||
"_compute_gradient_colors",
|
"_compute_gradient_colors",
|
||||||
"_SimpleNoise1D",
|
"_SimpleNoise1D",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
PictureColorStripStreams (expensive screen capture) are shared across multiple
|
PictureColorStripStreams (expensive screen capture) are shared across multiple
|
||||||
consumers via reference counting — processing runs once, not once per target.
|
consumers via reference counting — processing runs once, not once per target.
|
||||||
|
|
||||||
Count-dependent streams (static, gradient, color cycle, effect) are NOT shared.
|
Count-dependent streams (static, gradient, effect) are NOT shared.
|
||||||
Each consumer gets its own instance so it can configure an independent LED count
|
Each consumer gets its own instance so it can configure an independent LED count
|
||||||
without interfering with other targets.
|
without interfering with other targets.
|
||||||
"""
|
"""
|
||||||
@@ -12,7 +12,6 @@ from dataclasses import dataclass
|
|||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from ledgrab.core.processing.color_strip_stream import (
|
from ledgrab.core.processing.color_strip_stream import (
|
||||||
ColorCycleColorStripStream,
|
|
||||||
ColorStripStream,
|
ColorStripStream,
|
||||||
GradientColorStripStream,
|
GradientColorStripStream,
|
||||||
PictureColorStripStream,
|
PictureColorStripStream,
|
||||||
@@ -34,7 +33,6 @@ logger = get_logger(__name__)
|
|||||||
_SIMPLE_STREAM_MAP = {
|
_SIMPLE_STREAM_MAP = {
|
||||||
"static": StaticColorStripStream,
|
"static": StaticColorStripStream,
|
||||||
"gradient": GradientColorStripStream,
|
"gradient": GradientColorStripStream,
|
||||||
"color_cycle": ColorCycleColorStripStream,
|
|
||||||
"effect": EffectColorStripStream,
|
"effect": EffectColorStripStream,
|
||||||
"api_input": ApiInputColorStripStream,
|
"api_input": ApiInputColorStripStream,
|
||||||
"notification": NotificationColorStripStream,
|
"notification": NotificationColorStripStream,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Global daylight cycle preferences.
|
||||||
|
|
||||||
|
A single timezone applies to every daylight value source / color strip
|
||||||
|
source on the server, so it lives in the key/value settings table rather
|
||||||
|
than on each entity. The daylight streams read it on every wall-clock
|
||||||
|
sample (cheap dict lookup with a short cache window) so changing it in
|
||||||
|
the UI takes effect within ~1 second.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
DAYLIGHT_TIMEZONE_KEY = "daylight_timezone"
|
||||||
|
_CACHE_TTL_SECONDS = 1.0
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_cached_tz: str = ""
|
||||||
|
_cached_at: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _read_from_db() -> str:
|
||||||
|
"""Read the persisted timezone from the settings table.
|
||||||
|
|
||||||
|
Returns an empty string when unset, the table is unavailable, or
|
||||||
|
the stored value is corrupt — empty means "use system local time".
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from ledgrab.api.dependencies import get_database
|
||||||
|
|
||||||
|
raw = get_database().get_setting(DAYLIGHT_TIMEZONE_KEY)
|
||||||
|
except Exception as e: # pragma: no cover — DB not initialised yet, e.g. in tests
|
||||||
|
logger.debug("daylight timezone DB read failed: %s", e)
|
||||||
|
return ""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return ""
|
||||||
|
value = raw.get("value")
|
||||||
|
return str(value) if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_daylight_timezone() -> str:
|
||||||
|
"""Return the configured global daylight timezone (cached briefly)."""
|
||||||
|
global _cached_tz, _cached_at
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
with _lock:
|
||||||
|
if now - _cached_at < _CACHE_TTL_SECONDS:
|
||||||
|
return _cached_tz
|
||||||
|
|
||||||
|
fresh = _read_from_db()
|
||||||
|
with _lock:
|
||||||
|
_cached_tz = fresh
|
||||||
|
_cached_at = now
|
||||||
|
return fresh
|
||||||
|
|
||||||
|
|
||||||
|
def set_daylight_timezone(tz: Optional[str]) -> str:
|
||||||
|
"""Persist the global daylight timezone and refresh the cache.
|
||||||
|
|
||||||
|
Returns the canonicalised stored value (empty string for None / blank).
|
||||||
|
"""
|
||||||
|
canonical = str(tz or "").strip()
|
||||||
|
try:
|
||||||
|
from ledgrab.api.dependencies import get_database
|
||||||
|
|
||||||
|
get_database().set_setting(DAYLIGHT_TIMEZONE_KEY, {"value": canonical})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to persist daylight timezone: %s", e)
|
||||||
|
|
||||||
|
global _cached_tz, _cached_at
|
||||||
|
with _lock:
|
||||||
|
_cached_tz = canonical
|
||||||
|
_cached_at = time.monotonic()
|
||||||
|
return canonical
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_cache() -> None:
|
||||||
|
"""Force the next ``get_daylight_timezone`` call to re-read from DB."""
|
||||||
|
global _cached_at
|
||||||
|
with _lock:
|
||||||
|
_cached_at = 0.0
|
||||||
@@ -22,8 +22,33 @@ from ledgrab.utils import get_logger
|
|||||||
from ledgrab.utils.frame_limiter import FrameLimiter
|
from ledgrab.utils.frame_limiter import FrameLimiter
|
||||||
from ledgrab.utils.timer import high_resolution_timer
|
from ledgrab.utils.timer import high_resolution_timer
|
||||||
|
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
except ImportError: # pragma: no cover — pre-3.9 fallback, not expected in target envs
|
||||||
|
ZoneInfo = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
class ZoneInfoNotFoundError(Exception): # type: ignore[no-redef]
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_in_tz(tz_name: str) -> datetime.datetime:
|
||||||
|
"""Return current wall-clock time in the named IANA timezone.
|
||||||
|
|
||||||
|
Empty string means "use system local time". Unknown timezones fall
|
||||||
|
back to local time and log a warning once per unknown name.
|
||||||
|
"""
|
||||||
|
if not tz_name or ZoneInfo is None:
|
||||||
|
return datetime.datetime.now()
|
||||||
|
try:
|
||||||
|
return datetime.datetime.now(ZoneInfo(tz_name))
|
||||||
|
except ZoneInfoNotFoundError:
|
||||||
|
logger.warning(f"Unknown daylight timezone '{tz_name}' — falling back to system local")
|
||||||
|
return datetime.datetime.now()
|
||||||
|
|
||||||
|
|
||||||
# ── Daylight color table ────────────────────────────────────────────────
|
# ── Daylight color table ────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# Canonical hour control points (0–24) → RGB. Designed for a default
|
# Canonical hour control points (0–24) → RGB. Designed for a default
|
||||||
@@ -62,13 +87,19 @@ _daylight_lut: Optional[np.ndarray] = None
|
|||||||
# ── Solar position helpers ──────────────────────────────────────────────
|
# ── Solar position helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _compute_solar_times(latitude: float, longitude: float, day_of_year: int) -> tuple:
|
def _compute_solar_times(
|
||||||
"""Return (sunrise_hour, sunset_hour) in local solar time.
|
latitude: float,
|
||||||
|
longitude: float,
|
||||||
|
day_of_year: int,
|
||||||
|
utc_offset_hours: float = 0.0,
|
||||||
|
) -> tuple:
|
||||||
|
"""Return (sunrise_hour, sunset_hour) in the user's wall-clock time.
|
||||||
|
|
||||||
Uses simplified NOAA solar equations:
|
Uses simplified NOAA solar equations:
|
||||||
- declination: decl = 23.45 * sin(2π * (284 + doy) / 365)
|
- declination: decl = 23.45 * sin(2π * (284 + doy) / 365)
|
||||||
- hour angle: cos(ha) = -tan(lat) * tan(decl)
|
- hour angle: cos(ha) = -tan(lat) * tan(decl)
|
||||||
- sunrise/sunset: 12 ∓ ha/15, shifted by longitude
|
- solar noon (UTC): 12 - longitude/15
|
||||||
|
- wall-clock sunrise/sunset: solar_noon_utc + utc_offset ∓ ha/15
|
||||||
|
|
||||||
Polar day and polar night are clamped to visible ranges.
|
Polar day and polar night are clamped to visible ranges.
|
||||||
"""
|
"""
|
||||||
@@ -79,28 +110,48 @@ def _compute_solar_times(latitude: float, longitude: float, day_of_year: int) ->
|
|||||||
lat_rad = latitude * deg2rad
|
lat_rad = latitude * deg2rad
|
||||||
|
|
||||||
cos_ha = -math.tan(lat_rad) * math.tan(decl_rad)
|
cos_ha = -math.tan(lat_rad) * math.tan(decl_rad)
|
||||||
|
solar_noon_utc = 12.0 - longitude / 15.0
|
||||||
|
solar_noon_local = solar_noon_utc + utc_offset_hours
|
||||||
|
|
||||||
if cos_ha <= -1.0:
|
if cos_ha <= -1.0:
|
||||||
# Polar day — sun never sets
|
# Polar day — sun never sets; fake a long visible window
|
||||||
sunrise = 3.0
|
sunrise = solar_noon_local - 9.0
|
||||||
sunset = 21.0
|
sunset = solar_noon_local + 9.0
|
||||||
elif cos_ha >= 1.0:
|
elif cos_ha >= 1.0:
|
||||||
# Polar night — sun never rises
|
# Polar night — sun never rises; collapse to noon
|
||||||
sunrise = 12.0
|
sunrise = solar_noon_local
|
||||||
sunset = 12.0
|
sunset = solar_noon_local
|
||||||
else:
|
else:
|
||||||
ha_hours = math.acos(cos_ha) / (deg2rad * 15.0)
|
ha_hours = math.acos(cos_ha) / (deg2rad * 15.0)
|
||||||
lon_offset = longitude / 15.0
|
sunrise = solar_noon_local - ha_hours
|
||||||
solar_noon = 12.0 - lon_offset
|
sunset = solar_noon_local + ha_hours
|
||||||
sunrise = solar_noon - ha_hours
|
|
||||||
sunset = solar_noon + ha_hours
|
|
||||||
|
|
||||||
# Clamp to sane ranges
|
# Clamp to a safe range the LUT builder can render. With reasonable
|
||||||
sunrise = max(3.0, min(10.0, sunrise))
|
# tz/longitude pairs sunrise lands in (3..10) and sunset in (14..21);
|
||||||
sunset = max(14.0, min(21.0, sunset))
|
# we widen the clamp so weird tz/lon combinations still produce a
|
||||||
|
# usable curve instead of dividing by zero.
|
||||||
|
sunrise = max(0.5, min(11.5, sunrise))
|
||||||
|
sunset = max(12.5, min(23.5, sunset))
|
||||||
return sunrise, sunset
|
return sunrise, sunset
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_offset_hours_for(tz_name: str, when: Optional[datetime.datetime] = 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``.
|
||||||
|
"""
|
||||||
|
when = when or datetime.datetime.now()
|
||||||
|
if tz_name and ZoneInfo is not None:
|
||||||
|
try:
|
||||||
|
offset = when.replace(tzinfo=None).astimezone(ZoneInfo(tz_name)).utcoffset()
|
||||||
|
if offset is not None:
|
||||||
|
return offset.total_seconds() / 3600.0
|
||||||
|
except ZoneInfoNotFoundError:
|
||||||
|
pass
|
||||||
|
local_offset = when.astimezone().utcoffset()
|
||||||
|
return local_offset.total_seconds() / 3600.0 if local_offset else 0.0
|
||||||
|
|
||||||
|
|
||||||
def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray:
|
def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray:
|
||||||
"""Build a 1440-entry uint8 RGB LUT scaled to the given sunrise/sunset hours.
|
"""Build a 1440-entry uint8 RGB LUT scaled to the given sunrise/sunset hours.
|
||||||
|
|
||||||
@@ -198,9 +249,11 @@ class DaylightColorStripStream(ColorStripStream):
|
|||||||
with self._colors_lock:
|
with self._colors_lock:
|
||||||
self._colors: Optional[np.ndarray] = None
|
self._colors: Optional[np.ndarray] = None
|
||||||
|
|
||||||
def _get_lut_for_day(self, day_of_year: int) -> np.ndarray:
|
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)."""
|
"""Return a solar-time-aware LUT for the given day (cached)."""
|
||||||
sunrise, sunset = _compute_solar_times(self._latitude, self._longitude, day_of_year)
|
sunrise, sunset = _compute_solar_times(
|
||||||
|
self._latitude, self._longitude, day_of_year, utc_offset_hours
|
||||||
|
)
|
||||||
sr_key = int(round(sunrise * 60))
|
sr_key = int(round(sunrise * 60))
|
||||||
ss_key = int(round(sunset * 60))
|
ss_key = int(round(sunset * 60))
|
||||||
cache_key = (sr_key, ss_key)
|
cache_key = (sr_key, ss_key)
|
||||||
@@ -304,10 +357,16 @@ class DaylightColorStripStream(ColorStripStream):
|
|||||||
buf = _buf_a if _use_a else _buf_b
|
buf = _buf_a if _use_a else _buf_b
|
||||||
_use_a = not _use_a
|
_use_a = not _use_a
|
||||||
|
|
||||||
|
from ledgrab.core.processing.daylight_settings import (
|
||||||
|
get_daylight_timezone,
|
||||||
|
)
|
||||||
|
|
||||||
|
tz_name = get_daylight_timezone()
|
||||||
if self._use_real_time:
|
if self._use_real_time:
|
||||||
now = datetime.datetime.now()
|
now = _now_in_tz(tz_name)
|
||||||
day_of_year = now.timetuple().tm_yday
|
day_of_year = now.timetuple().tm_yday
|
||||||
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
|
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
|
||||||
|
utc_offset_hours = _utc_offset_hours_for(tz_name, now)
|
||||||
else:
|
else:
|
||||||
# Simulated: speed=1.0 → full 24h in 240s.
|
# Simulated: speed=1.0 → full 24h in 240s.
|
||||||
# Use summer solstice (day 172) for maximum day length.
|
# Use summer solstice (day 172) for maximum day length.
|
||||||
@@ -315,8 +374,9 @@ class DaylightColorStripStream(ColorStripStream):
|
|||||||
cycle_seconds = 240.0 / max(speed, 0.01)
|
cycle_seconds = 240.0 / max(speed, 0.01)
|
||||||
phase = (t % cycle_seconds) / cycle_seconds
|
phase = (t % cycle_seconds) / cycle_seconds
|
||||||
minute_of_day = phase * 1440.0
|
minute_of_day = phase * 1440.0
|
||||||
|
utc_offset_hours = _utc_offset_hours_for(tz_name)
|
||||||
|
|
||||||
lut = self._get_lut_for_day(day_of_year)
|
lut = self._get_lut_for_day(day_of_year, utc_offset_hours)
|
||||||
idx = int(minute_of_day) % 1440
|
idx = int(minute_of_day) % 1440
|
||||||
color = lut[idx]
|
color = lut[idx]
|
||||||
buf[:] = color
|
buf[:] = color
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
gradient_store=deps.gradient_store,
|
gradient_store=deps.gradient_store,
|
||||||
event_bus=deps.game_event_bus,
|
event_bus=deps.game_event_bus,
|
||||||
audio_processing_template_store=deps.audio_processing_template_store,
|
audio_processing_template_store=deps.audio_processing_template_store,
|
||||||
|
sync_clock_manager=deps.sync_clock_manager,
|
||||||
)
|
)
|
||||||
if deps.value_source_store
|
if deps.value_source_store
|
||||||
else None
|
else None
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ if TYPE_CHECKING:
|
|||||||
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
|
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
|
||||||
from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager
|
from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager
|
||||||
from ledgrab.core.processing.live_stream_manager import LiveStreamManager
|
from ledgrab.core.processing.live_stream_manager import LiveStreamManager
|
||||||
|
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
||||||
from ledgrab.storage.audio_source_store import AudioSourceStore
|
from ledgrab.storage.audio_source_store import AudioSourceStore
|
||||||
from ledgrab.storage.value_source import ValueSource
|
from ledgrab.storage.value_source import ValueSource
|
||||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||||
@@ -599,31 +600,61 @@ class DaylightValueStream(ValueStream):
|
|||||||
speed: float = 1.0,
|
speed: float = 1.0,
|
||||||
use_real_time: bool = False,
|
use_real_time: bool = False,
|
||||||
latitude: float = 50.0,
|
latitude: float = 50.0,
|
||||||
|
longitude: float = 0.0,
|
||||||
min_value: float = 0.0,
|
min_value: float = 0.0,
|
||||||
max_value: float = 1.0,
|
max_value: float = 1.0,
|
||||||
):
|
):
|
||||||
from ledgrab.core.processing.daylight_stream import _get_daylight_lut
|
from ledgrab.core.processing.daylight_stream import _get_daylight_lut
|
||||||
|
|
||||||
self._lut = _get_daylight_lut()
|
self._default_lut = _get_daylight_lut()
|
||||||
self._speed = speed
|
self._speed = speed
|
||||||
self._use_real_time = use_real_time
|
self._use_real_time = use_real_time
|
||||||
self._latitude = latitude
|
self._latitude = latitude
|
||||||
|
self._longitude = longitude
|
||||||
self._min = min_value
|
self._min = min_value
|
||||||
self._max = max_value
|
self._max = max_value
|
||||||
self._start_time = time.perf_counter()
|
self._start_time = time.perf_counter()
|
||||||
|
# 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:
|
||||||
|
if day_of_year is None:
|
||||||
|
return self._default_lut
|
||||||
|
from ledgrab.core.processing.daylight_stream import (
|
||||||
|
_build_lut_for_solar_times,
|
||||||
|
_compute_solar_times,
|
||||||
|
)
|
||||||
|
|
||||||
|
sr, ss = _compute_solar_times(
|
||||||
|
self._latitude, self._longitude, day_of_year, utc_offset_hours
|
||||||
|
)
|
||||||
|
key = (int(round(sr * 60)), int(round(ss * 60)))
|
||||||
|
lut = self._lut_cache.get(key)
|
||||||
|
if lut is None:
|
||||||
|
lut = _build_lut_for_solar_times(sr, ss)
|
||||||
|
if len(self._lut_cache) > 8:
|
||||||
|
self._lut_cache.clear()
|
||||||
|
self._lut_cache[key] = lut
|
||||||
|
return lut
|
||||||
|
|
||||||
def get_value(self) -> float:
|
def get_value(self) -> float:
|
||||||
|
from ledgrab.core.processing.daylight_settings import get_daylight_timezone
|
||||||
|
from ledgrab.core.processing.daylight_stream import _now_in_tz, _utc_offset_hours_for
|
||||||
|
|
||||||
|
tz_name = get_daylight_timezone()
|
||||||
if self._use_real_time:
|
if self._use_real_time:
|
||||||
now = datetime.now()
|
now = _now_in_tz(tz_name)
|
||||||
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
|
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
|
||||||
|
lut = self._resolve_lut(now.timetuple().tm_yday, _utc_offset_hours_for(tz_name, now))
|
||||||
else:
|
else:
|
||||||
t_elapsed = time.perf_counter() - self._start_time
|
t_elapsed = time.perf_counter() - self._start_time
|
||||||
cycle_seconds = 240.0 / max(self._speed, 0.01)
|
cycle_seconds = 240.0 / max(self._speed, 0.01)
|
||||||
phase = (t_elapsed % cycle_seconds) / cycle_seconds
|
phase = (t_elapsed % cycle_seconds) / cycle_seconds
|
||||||
minute_of_day = phase * 1440.0
|
minute_of_day = phase * 1440.0
|
||||||
|
lut = self._default_lut
|
||||||
|
|
||||||
idx = int(minute_of_day) % 1440
|
idx = int(minute_of_day) % 1440
|
||||||
r, g, b = self._lut[idx]
|
r, g, b = lut[idx]
|
||||||
|
|
||||||
# BT.601 luminance → 0..1
|
# BT.601 luminance → 0..1
|
||||||
luminance = (0.299 * float(r) + 0.587 * float(g) + 0.114 * float(b)) / 255.0
|
luminance = (0.299 * float(r) + 0.587 * float(g) + 0.114 * float(b)) / 255.0
|
||||||
@@ -637,8 +668,10 @@ class DaylightValueStream(ValueStream):
|
|||||||
self._speed = source.speed
|
self._speed = source.speed
|
||||||
self._use_real_time = source.use_real_time
|
self._use_real_time = source.use_real_time
|
||||||
self._latitude = source.latitude
|
self._latitude = source.latitude
|
||||||
|
self._longitude = source.longitude
|
||||||
self._min = source.min_value
|
self._min = source.min_value
|
||||||
self._max = source.max_value
|
self._max = source.max_value
|
||||||
|
self._lut_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -669,10 +702,34 @@ class StaticColorValueStream(ValueStream):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AnimatedColorValueStream(ValueStream):
|
def _ease_color_frac(t: float, easing: str) -> float:
|
||||||
"""Cycles through a list of colors over time."""
|
"""Remap a 0..1 segment fraction through a named easing curve.
|
||||||
|
|
||||||
def __init__(self, colors, speed=10.0, easing="linear"):
|
Unknown names fall back to linear so older configs and forward-compat
|
||||||
|
payloads keep working.
|
||||||
|
"""
|
||||||
|
if easing == "ease_in":
|
||||||
|
return t * t * t
|
||||||
|
if easing == "ease_out":
|
||||||
|
u = 1.0 - t
|
||||||
|
return 1.0 - u * u * u
|
||||||
|
if easing == "ease_in_out":
|
||||||
|
return t * t * (3.0 - 2.0 * t)
|
||||||
|
if easing == "sine":
|
||||||
|
return 0.5 - 0.5 * math.cos(math.pi * t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
class AnimatedColorValueStream(ValueStream):
|
||||||
|
"""Cycles through a list of colors over time.
|
||||||
|
|
||||||
|
When a ``clock`` runtime is provided, animation is driven by the
|
||||||
|
clock's pause-aware elapsed time and speed multiplier so multiple
|
||||||
|
streams sharing the same clock stay in lockstep. When no clock is
|
||||||
|
set, falls back to wall-clock time scaled by ``speed`` (cycles/min).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, colors, speed=10.0, easing="linear", clock=None):
|
||||||
self._colors = [
|
self._colors = [
|
||||||
(int(c[0]), int(c[1]), int(c[2]))
|
(int(c[0]), int(c[1]), int(c[2]))
|
||||||
for c in (colors or [[255, 0, 0], [0, 255, 0], [0, 0, 255]])
|
for c in (colors or [[255, 0, 0], [0, 255, 0], [0, 0, 255]])
|
||||||
@@ -681,24 +738,47 @@ class AnimatedColorValueStream(ValueStream):
|
|||||||
self._speed = max(0.01, float(speed))
|
self._speed = max(0.01, float(speed))
|
||||||
self._easing = easing
|
self._easing = easing
|
||||||
self._start_time = 0.0
|
self._start_time = 0.0
|
||||||
|
self._clock = clock
|
||||||
|
# Last frame state — held while the clock is paused so get_color()
|
||||||
|
# returns a stable color instead of jumping.
|
||||||
|
self._last_phase = 0.0
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
self._start_time = time.monotonic()
|
self._start_time = time.monotonic()
|
||||||
|
|
||||||
|
def set_clock(self, clock) -> None:
|
||||||
|
"""Set or clear the sync clock runtime. Thread-safe (atomic ref swap)."""
|
||||||
|
self._clock = clock
|
||||||
|
|
||||||
def get_value(self) -> float:
|
def get_value(self) -> float:
|
||||||
r, g, b = self.get_color()
|
r, g, b = self.get_color()
|
||||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
|
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
|
||||||
|
|
||||||
def get_color(self) -> tuple:
|
def get_color(self) -> tuple:
|
||||||
elapsed = time.monotonic() - self._start_time
|
clock = self._clock
|
||||||
cycle_time = 60.0 / self._speed
|
|
||||||
n = len(self._colors)
|
n = len(self._colors)
|
||||||
|
if clock is not None:
|
||||||
|
# Clock provides real elapsed seconds (pause-aware) and a speed
|
||||||
|
# multiplier. We treat self._speed as the base cpm and apply the
|
||||||
|
# clock's speed on top, matching the convention used by CSS
|
||||||
|
# animation streams.
|
||||||
|
cycle_time = 60.0 / max(0.01, self._speed * float(clock.speed))
|
||||||
|
if not clock.is_running:
|
||||||
|
phase = self._last_phase
|
||||||
|
else:
|
||||||
|
elapsed = clock.get_time()
|
||||||
|
phase = (elapsed / cycle_time * n) % n
|
||||||
|
self._last_phase = phase
|
||||||
|
else:
|
||||||
|
elapsed = time.monotonic() - self._start_time
|
||||||
|
cycle_time = 60.0 / self._speed
|
||||||
|
phase = (elapsed / cycle_time * n) % n
|
||||||
|
self._last_phase = phase
|
||||||
|
|
||||||
if self._easing == "step":
|
if self._easing == "step":
|
||||||
idx = int((elapsed / cycle_time * n) % n)
|
return self._colors[int(phase) % n]
|
||||||
return self._colors[idx]
|
|
||||||
phase = (elapsed / cycle_time * n) % n
|
|
||||||
idx = int(phase)
|
idx = int(phase)
|
||||||
frac = phase - idx
|
frac = _ease_color_frac(phase - idx, self._easing)
|
||||||
c1 = self._colors[idx % n]
|
c1 = self._colors[idx % n]
|
||||||
c2 = self._colors[(idx + 1) % n]
|
c2 = self._colors[(idx + 1) % n]
|
||||||
return (
|
return (
|
||||||
@@ -1466,6 +1546,7 @@ class ValueStreamManager:
|
|||||||
gradient_store: Optional[Any] = None,
|
gradient_store: Optional[Any] = None,
|
||||||
event_bus: Optional["GameEventBus"] = None,
|
event_bus: Optional["GameEventBus"] = None,
|
||||||
audio_processing_template_store=None,
|
audio_processing_template_store=None,
|
||||||
|
sync_clock_manager: Optional["SyncClockManager"] = None,
|
||||||
):
|
):
|
||||||
self._value_source_store = value_source_store
|
self._value_source_store = value_source_store
|
||||||
self._audio_capture_manager = audio_capture_manager
|
self._audio_capture_manager = audio_capture_manager
|
||||||
@@ -1477,8 +1558,12 @@ class ValueStreamManager:
|
|||||||
self._gradient_store = gradient_store
|
self._gradient_store = gradient_store
|
||||||
self._event_bus = event_bus
|
self._event_bus = event_bus
|
||||||
self._audio_processing_template_store = audio_processing_template_store
|
self._audio_processing_template_store = audio_processing_template_store
|
||||||
|
self._sync_clock_manager = sync_clock_manager
|
||||||
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
|
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
|
||||||
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
|
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
|
||||||
|
# Tracks which clock_id (if any) was acquired for each stream so we
|
||||||
|
# can release/swap it without re-querying the store at teardown time.
|
||||||
|
self._stream_clock_ids: Dict[str, str] = {} # vs_id → clock_id
|
||||||
|
|
||||||
def acquire(self, vs_id: str) -> ValueStream:
|
def acquire(self, vs_id: str) -> ValueStream:
|
||||||
"""Get or create a shared ValueStream for the given ValueSource.
|
"""Get or create a shared ValueStream for the given ValueSource.
|
||||||
@@ -1492,7 +1577,7 @@ class ValueStreamManager:
|
|||||||
return self._streams[vs_id]
|
return self._streams[vs_id]
|
||||||
|
|
||||||
source = self._value_source_store.get_source(vs_id)
|
source = self._value_source_store.get_source(vs_id)
|
||||||
stream = self._create_stream(source)
|
stream = self._create_stream(source, vs_id)
|
||||||
stream.start()
|
stream.start()
|
||||||
self._streams[vs_id] = stream
|
self._streams[vs_id] = stream
|
||||||
self._ref_counts[vs_id] = 1
|
self._ref_counts[vs_id] = 1
|
||||||
@@ -1512,6 +1597,7 @@ class ValueStreamManager:
|
|||||||
if stream:
|
if stream:
|
||||||
stream.stop()
|
stream.stop()
|
||||||
del self._ref_counts[vs_id]
|
del self._ref_counts[vs_id]
|
||||||
|
self._release_clock_for(vs_id)
|
||||||
logger.info(f"Released value stream {vs_id} (last ref)")
|
logger.info(f"Released value stream {vs_id} (last ref)")
|
||||||
else:
|
else:
|
||||||
logger.info(f"Released ref for value stream {vs_id} (refs={refs})")
|
logger.info(f"Released ref for value stream {vs_id} (refs={refs})")
|
||||||
@@ -1527,8 +1613,53 @@ class ValueStreamManager:
|
|||||||
stream = self._streams.get(vs_id)
|
stream = self._streams.get(vs_id)
|
||||||
if stream:
|
if stream:
|
||||||
stream.update_source(source)
|
stream.update_source(source)
|
||||||
|
self._sync_clock_binding(vs_id, source, stream)
|
||||||
logger.debug(f"Updated value stream {vs_id}")
|
logger.debug(f"Updated value stream {vs_id}")
|
||||||
|
|
||||||
|
def _sync_clock_binding(self, vs_id: str, source: "ValueSource", stream: ValueStream) -> None:
|
||||||
|
"""Hot-swap the sync-clock runtime attached to *stream* if needed."""
|
||||||
|
if not self._sync_clock_manager or not hasattr(stream, "set_clock"):
|
||||||
|
return
|
||||||
|
new_clock_id = getattr(source, "clock_id", None) or None
|
||||||
|
old_clock_id = self._stream_clock_ids.get(vs_id)
|
||||||
|
if new_clock_id == old_clock_id:
|
||||||
|
return
|
||||||
|
new_runtime = None
|
||||||
|
if new_clock_id:
|
||||||
|
try:
|
||||||
|
new_runtime = self._sync_clock_manager.acquire(new_clock_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Could not acquire sync clock %s for value stream %s: %s",
|
||||||
|
new_clock_id,
|
||||||
|
vs_id,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
new_runtime = None
|
||||||
|
new_clock_id = None
|
||||||
|
try:
|
||||||
|
stream.set_clock(new_runtime)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("set_clock failed on value stream %s: %s", vs_id, e)
|
||||||
|
if new_clock_id:
|
||||||
|
self._stream_clock_ids[vs_id] = new_clock_id
|
||||||
|
else:
|
||||||
|
self._stream_clock_ids.pop(vs_id, None)
|
||||||
|
if old_clock_id:
|
||||||
|
try:
|
||||||
|
self._sync_clock_manager.release(old_clock_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Sync clock release for %s: %s", old_clock_id, e)
|
||||||
|
|
||||||
|
def _release_clock_for(self, vs_id: str) -> None:
|
||||||
|
"""Release the sync clock acquired for *vs_id* (if any)."""
|
||||||
|
clock_id = self._stream_clock_ids.pop(vs_id, None)
|
||||||
|
if clock_id and self._sync_clock_manager:
|
||||||
|
try:
|
||||||
|
self._sync_clock_manager.release(clock_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Sync clock release for %s: %s", clock_id, e)
|
||||||
|
|
||||||
def refresh_audio_filter_pipelines(self, template_id: str) -> None:
|
def refresh_audio_filter_pipelines(self, template_id: str) -> None:
|
||||||
"""Rebuild audio filter pipelines for any running AudioValueStream
|
"""Rebuild audio filter pipelines for any running AudioValueStream
|
||||||
that references the given audio processing template ID.
|
that references the given audio processing template ID.
|
||||||
@@ -1555,11 +1686,19 @@ class ValueStreamManager:
|
|||||||
stream.stop()
|
stream.stop()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping value stream {vs_id}: {e}")
|
logger.error(f"Error stopping value stream {vs_id}: {e}")
|
||||||
|
# Release any sync clocks held by streams.
|
||||||
|
if self._sync_clock_manager:
|
||||||
|
for vs_id, clock_id in self._stream_clock_ids.items():
|
||||||
|
try:
|
||||||
|
self._sync_clock_manager.release(clock_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Sync clock release for %s during shutdown: %s", clock_id, e)
|
||||||
|
self._stream_clock_ids.clear()
|
||||||
self._streams.clear()
|
self._streams.clear()
|
||||||
self._ref_counts.clear()
|
self._ref_counts.clear()
|
||||||
logger.info("Released all value streams")
|
logger.info("Released all value streams")
|
||||||
|
|
||||||
def _create_stream(self, source: "ValueSource") -> ValueStream:
|
def _create_stream(self, source: "ValueSource", vs_id: Optional[str] = None) -> ValueStream:
|
||||||
"""Factory: create the appropriate ValueStream for a ValueSource."""
|
"""Factory: create the appropriate ValueStream for a ValueSource."""
|
||||||
from ledgrab.storage.value_source import (
|
from ledgrab.storage.value_source import (
|
||||||
AdaptiveValueSource,
|
AdaptiveValueSource,
|
||||||
@@ -1608,6 +1747,7 @@ class ValueStreamManager:
|
|||||||
speed=source.speed,
|
speed=source.speed,
|
||||||
use_real_time=source.use_real_time,
|
use_real_time=source.use_real_time,
|
||||||
latitude=source.latitude,
|
latitude=source.latitude,
|
||||||
|
longitude=source.longitude,
|
||||||
min_value=source.min_value,
|
min_value=source.min_value,
|
||||||
max_value=source.max_value,
|
max_value=source.max_value,
|
||||||
)
|
)
|
||||||
@@ -1634,10 +1774,24 @@ class ValueStreamManager:
|
|||||||
return StaticColorValueStream(color=source.color)
|
return StaticColorValueStream(color=source.color)
|
||||||
|
|
||||||
if isinstance(source, AnimatedColorValueSource):
|
if isinstance(source, AnimatedColorValueSource):
|
||||||
|
clock_runtime = None
|
||||||
|
if source.clock_id and self._sync_clock_manager:
|
||||||
|
try:
|
||||||
|
clock_runtime = self._sync_clock_manager.acquire(source.clock_id)
|
||||||
|
if vs_id is not None:
|
||||||
|
self._stream_clock_ids[vs_id] = source.clock_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Could not acquire sync clock %s for value source %s: %s",
|
||||||
|
source.clock_id,
|
||||||
|
source.id,
|
||||||
|
e,
|
||||||
|
)
|
||||||
return AnimatedColorValueStream(
|
return AnimatedColorValueStream(
|
||||||
colors=source.colors,
|
colors=source.colors,
|
||||||
speed=source.speed,
|
speed=source.speed,
|
||||||
easing=source.easing,
|
easing=source.easing,
|
||||||
|
clock=clock_runtime,
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(source, AdaptiveTimeColorValueSource):
|
if isinstance(source, AdaptiveTimeColorValueSource):
|
||||||
|
|||||||
@@ -1159,6 +1159,116 @@ textarea:focus-visible {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Timezone picker (Settings modal) ──────────────────────
|
||||||
|
Refined "instrument readout" trigger that matches the lux
|
||||||
|
sectional design language used in the rest of the modal:
|
||||||
|
- hairline border, deeper bg, monospaced offset chip
|
||||||
|
- region icon stenciled in --ch-cyan to echo section dots
|
||||||
|
- subtle hover glow on the channel hue, not the primary green */
|
||||||
|
|
||||||
|
.tz-picker-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-picker-wrap .entity-select-trigger {
|
||||||
|
--tz-ch: var(--ch-cyan, var(--primary-color));
|
||||||
|
background: var(--lux-bg-1, var(--bg-color));
|
||||||
|
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||||
|
border-radius: var(--lux-r-md, 6px);
|
||||||
|
padding: 10px 12px;
|
||||||
|
gap: 12px;
|
||||||
|
font-feature-settings: "ss01", "tnum";
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--lux-ink, var(--text-color));
|
||||||
|
transition:
|
||||||
|
border-color 180ms var(--ease-out, ease-out),
|
||||||
|
background 180ms var(--ease-out, ease-out),
|
||||||
|
box-shadow 220ms var(--ease-out, ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-picker-wrap .entity-select-trigger::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
color-mix(in srgb, var(--tz-ch) 80%, transparent),
|
||||||
|
color-mix(in srgb, var(--tz-ch) 0%, transparent) 70%
|
||||||
|
);
|
||||||
|
border-radius: var(--lux-r-md, 6px) 0 0 var(--lux-r-md, 6px);
|
||||||
|
opacity: 0.75;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 220ms var(--ease-out, ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-picker-wrap .entity-select-trigger:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--tz-ch) 55%, var(--lux-line-bold, var(--border-color)));
|
||||||
|
background: var(--lux-bg-2, var(--bg-secondary));
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tz-ch) 18%, transparent),
|
||||||
|
0 8px 24px -10px color-mix(in srgb, var(--tz-ch) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-picker-wrap .entity-select-trigger:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-picker-wrap .entity-select-trigger:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--tz-ch);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--tz-ch) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-picker-wrap .es-trigger-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--lux-r-sm, 3px);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: color-mix(in srgb, var(--tz-ch) 12%, transparent);
|
||||||
|
border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--tz-ch) 25%, transparent);
|
||||||
|
color: var(--tz-ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-picker-wrap .es-trigger-icon .icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-picker-wrap .es-trigger-label {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--lux-ink, var(--text-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Render the offset segment after the city in monospaced "panel-meter" type */
|
||||||
|
.tz-picker-wrap .es-trigger-label {
|
||||||
|
/* Allow ellipsis to keep the trigger compact in narrow layouts. */
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-picker-wrap .es-trigger-arrow {
|
||||||
|
color: var(--lux-ink-mute, var(--text-secondary));
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
transition: transform 220ms var(--ease-out, ease-out), color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-picker-wrap:hover .es-trigger-arrow {
|
||||||
|
color: var(--tz-ch);
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When the value is the system default (empty string), dim the label slightly
|
||||||
|
so users can see it is in the "no override" state at a glance. */
|
||||||
|
.tz-picker-wrap .entity-select-trigger:has(.es-trigger-none) {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Scroll-to-top button ── */
|
/* ── Scroll-to-top button ── */
|
||||||
|
|
||||||
.scroll-to-top {
|
.scroll-to-top {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -66,7 +66,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
loadPictureSources, switchStreamTab,
|
loadPictureSources, switchStreamTab,
|
||||||
showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate,
|
showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate,
|
||||||
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest,
|
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest, openTestDisplayPicker,
|
||||||
showAddAudioTemplateModal, editAudioTemplate, closeAudioTemplateModal, saveAudioTemplate, deleteAudioTemplate,
|
showAddAudioTemplateModal, editAudioTemplate, closeAudioTemplateModal, saveAudioTemplate, deleteAudioTemplate,
|
||||||
cloneAudioTemplate, onAudioEngineChange,
|
cloneAudioTemplate, onAudioEngineChange,
|
||||||
showTestAudioTemplateModal, closeTestAudioTemplateModal, startAudioTemplateTest,
|
showTestAudioTemplateModal, closeTestAudioTemplateModal, startAudioTemplateTest,
|
||||||
@@ -131,7 +131,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
||||||
onCSSTypeChange, onEffectTypeChange, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
|
onCSSTypeChange, onEffectTypeChange, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
|
||||||
colorCycleAddColor, colorCycleRemoveColor,
|
|
||||||
compositeAddLayer, compositeRemoveLayer,
|
compositeAddLayer, compositeRemoveLayer,
|
||||||
mappedAddZone, mappedRemoveZone,
|
mappedAddZone, mappedRemoveZone,
|
||||||
onAudioVizChange,
|
onAudioVizChange,
|
||||||
@@ -224,6 +223,7 @@ import {
|
|||||||
openLogOverlay, closeLogOverlay,
|
openLogOverlay, closeLogOverlay,
|
||||||
loadLogLevel, setLogLevel,
|
loadLogLevel, setLogLevel,
|
||||||
loadShutdownAction, setShutdownAction,
|
loadShutdownAction, setShutdownAction,
|
||||||
|
loadDaylightTimezone, saveDaylightTimezone,
|
||||||
requestNotifPermissionFromSettings, testNotifFromSettings,
|
requestNotifPermissionFromSettings, testNotifFromSettings,
|
||||||
saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
|
saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
|
||||||
} from './features/settings.ts';
|
} from './features/settings.ts';
|
||||||
@@ -340,6 +340,7 @@ Object.assign(window, {
|
|||||||
closeTestTemplateModal,
|
closeTestTemplateModal,
|
||||||
onEngineChange,
|
onEngineChange,
|
||||||
runTemplateTest,
|
runTemplateTest,
|
||||||
|
openTestDisplayPicker,
|
||||||
updateCaptureDuration,
|
updateCaptureDuration,
|
||||||
showAddStreamModal,
|
showAddStreamModal,
|
||||||
editStream,
|
editStream,
|
||||||
@@ -488,8 +489,6 @@ Object.assign(window, {
|
|||||||
onCSSClockChange,
|
onCSSClockChange,
|
||||||
onAnimationTypeChange,
|
onAnimationTypeChange,
|
||||||
onDaylightRealTimeChange,
|
onDaylightRealTimeChange,
|
||||||
colorCycleAddColor,
|
|
||||||
colorCycleRemoveColor,
|
|
||||||
compositeAddLayer,
|
compositeAddLayer,
|
||||||
compositeRemoveLayer,
|
compositeRemoveLayer,
|
||||||
mappedAddZone,
|
mappedAddZone,
|
||||||
@@ -630,6 +629,8 @@ Object.assign(window, {
|
|||||||
setLogLevel,
|
setLogLevel,
|
||||||
loadShutdownAction,
|
loadShutdownAction,
|
||||||
setShutdownAction,
|
setShutdownAction,
|
||||||
|
loadDaylightTimezone,
|
||||||
|
saveDaylightTimezone,
|
||||||
requestNotifPermissionFromSettings,
|
requestNotifPermissionFromSettings,
|
||||||
testNotifFromSettings,
|
testNotifFromSettings,
|
||||||
saveExternalUrl,
|
saveExternalUrl,
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* Daylight-cycle timezone picker.
|
||||||
|
*
|
||||||
|
* Exposes:
|
||||||
|
* - `getTimezoneItems()` — IconSelectItem[] for the EntityPalette / EntitySelect
|
||||||
|
* - `enhanceTimezoneSelect(target, value, onChange?)` — replace a `<select>` with
|
||||||
|
* a refined EntitySelect picker. Idempotent; safe to call on every modal open.
|
||||||
|
* - `populateTimezoneSelect(id, value)` — back-compat for legacy callers that
|
||||||
|
* still want a plain native `<select>`.
|
||||||
|
*
|
||||||
|
* Each entry exposes a friendly city name and the timezone's *current* UTC
|
||||||
|
* offset (computed via `Intl.DateTimeFormat`), so users see `Berlin · UTC+02:00`
|
||||||
|
* instead of bare IANA strings like `Europe/Berlin`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { t } from './i18n.ts';
|
||||||
|
import {
|
||||||
|
ICON_GLOBE,
|
||||||
|
ICON_SPARKLES,
|
||||||
|
ICON_TARGET_ICON,
|
||||||
|
ICON_CLOCK,
|
||||||
|
} from './icons.ts';
|
||||||
|
import type { IconSelectItem } from './icon-select.ts';
|
||||||
|
import { EntitySelect } from './entity-palette.ts';
|
||||||
|
|
||||||
|
type RegionKey =
|
||||||
|
| 'utc'
|
||||||
|
| 'europe'
|
||||||
|
| 'africa'
|
||||||
|
| 'me'
|
||||||
|
| 'asia'
|
||||||
|
| 'pacific'
|
||||||
|
| 'americas';
|
||||||
|
|
||||||
|
interface TimezoneEntry {
|
||||||
|
iana: string;
|
||||||
|
region: RegionKey;
|
||||||
|
city: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIMEZONES: ReadonlyArray<TimezoneEntry> = [
|
||||||
|
{ iana: 'UTC', region: 'utc', city: 'UTC' },
|
||||||
|
|
||||||
|
// Europe
|
||||||
|
{ iana: 'Europe/London', region: 'europe', city: 'London' },
|
||||||
|
{ iana: 'Europe/Lisbon', region: 'europe', city: 'Lisbon' },
|
||||||
|
{ iana: 'Europe/Paris', region: 'europe', city: 'Paris' },
|
||||||
|
{ iana: 'Europe/Berlin', region: 'europe', city: 'Berlin' },
|
||||||
|
{ iana: 'Europe/Warsaw', region: 'europe', city: 'Warsaw' },
|
||||||
|
{ iana: 'Europe/Helsinki', region: 'europe', city: 'Helsinki' },
|
||||||
|
{ iana: 'Europe/Athens', region: 'europe', city: 'Athens' },
|
||||||
|
{ iana: 'Europe/Moscow', region: 'europe', city: 'Moscow' },
|
||||||
|
{ iana: 'Europe/Istanbul', region: 'europe', city: 'Istanbul' },
|
||||||
|
|
||||||
|
// Africa & Middle East
|
||||||
|
{ iana: 'Africa/Cairo', region: 'africa', city: 'Cairo' },
|
||||||
|
{ iana: 'Africa/Lagos', region: 'africa', city: 'Lagos' },
|
||||||
|
{ iana: 'Africa/Johannesburg', region: 'africa', city: 'Johannesburg' },
|
||||||
|
{ iana: 'Asia/Dubai', region: 'me', city: 'Dubai' },
|
||||||
|
{ iana: 'Asia/Tehran', region: 'me', city: 'Tehran' },
|
||||||
|
|
||||||
|
// Asia
|
||||||
|
{ iana: 'Asia/Karachi', region: 'asia', city: 'Karachi' },
|
||||||
|
{ iana: 'Asia/Kolkata', region: 'asia', city: 'Kolkata' },
|
||||||
|
{ iana: 'Asia/Bangkok', region: 'asia', city: 'Bangkok' },
|
||||||
|
{ iana: 'Asia/Jakarta', region: 'asia', city: 'Jakarta' },
|
||||||
|
{ iana: 'Asia/Singapore', region: 'asia', city: 'Singapore' },
|
||||||
|
{ iana: 'Asia/Hong_Kong', region: 'asia', city: 'Hong Kong' },
|
||||||
|
{ iana: 'Asia/Shanghai', region: 'asia', city: 'Shanghai' },
|
||||||
|
{ iana: 'Asia/Seoul', region: 'asia', city: 'Seoul' },
|
||||||
|
{ iana: 'Asia/Tokyo', region: 'asia', city: 'Tokyo' },
|
||||||
|
|
||||||
|
// Pacific
|
||||||
|
{ iana: 'Australia/Perth', region: 'pacific', city: 'Perth' },
|
||||||
|
{ iana: 'Australia/Adelaide', region: 'pacific', city: 'Adelaide' },
|
||||||
|
{ iana: 'Australia/Sydney', region: 'pacific', city: 'Sydney' },
|
||||||
|
{ iana: 'Pacific/Auckland', region: 'pacific', city: 'Auckland' },
|
||||||
|
{ iana: 'Pacific/Honolulu', region: 'pacific', city: 'Honolulu' },
|
||||||
|
|
||||||
|
// Americas
|
||||||
|
{ iana: 'America/Anchorage', region: 'americas', city: 'Anchorage' },
|
||||||
|
{ iana: 'America/Los_Angeles', region: 'americas', city: 'Los Angeles' },
|
||||||
|
{ iana: 'America/Denver', region: 'americas', city: 'Denver' },
|
||||||
|
{ iana: 'America/Chicago', region: 'americas', city: 'Chicago' },
|
||||||
|
{ iana: 'America/Mexico_City', region: 'americas', city: 'Mexico City' },
|
||||||
|
{ iana: 'America/New_York', region: 'americas', city: 'New York' },
|
||||||
|
{ iana: 'America/Toronto', region: 'americas', city: 'Toronto' },
|
||||||
|
{ iana: 'America/Sao_Paulo', region: 'americas', city: 'São Paulo' },
|
||||||
|
{ iana: 'America/Argentina/Buenos_Aires', region: 'americas', city: 'Buenos Aires' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const REGION_FALLBACK: Record<RegionKey, string> = {
|
||||||
|
utc: 'UTC',
|
||||||
|
europe: 'Europe',
|
||||||
|
africa: 'Africa',
|
||||||
|
me: 'Middle East',
|
||||||
|
asia: 'Asia',
|
||||||
|
pacific: 'Pacific',
|
||||||
|
americas: 'Americas',
|
||||||
|
};
|
||||||
|
|
||||||
|
function _detectedTimezone(): string {
|
||||||
|
try {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute the current UTC offset for a given IANA zone, formatted as `UTC+02:00`. */
|
||||||
|
function _utcOffsetLabel(iana: string): string {
|
||||||
|
try {
|
||||||
|
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: iana,
|
||||||
|
timeZoneName: 'longOffset',
|
||||||
|
});
|
||||||
|
const parts = fmt.formatToParts(new Date());
|
||||||
|
const raw = parts.find(p => p.type === 'timeZoneName')?.value || '';
|
||||||
|
// Intl returns 'GMT+02:00' for offsets and 'GMT' for UTC. Normalize.
|
||||||
|
if (raw === 'GMT' || raw === '') return 'UTC';
|
||||||
|
return raw.replace(/^GMT/, 'UTC');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _friendlyCityFromIana(iana: string): string {
|
||||||
|
const last = iana.split('/').pop() || iana;
|
||||||
|
return last.replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _regionLabel(region: RegionKey): string {
|
||||||
|
return t(`common.daylight_tz.region.${region}`) || REGION_FALLBACK[region];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatRowLabel(city: string, offset: string): string {
|
||||||
|
// Em-dash separator, monospaced offset rendered via CSS on the popup.
|
||||||
|
return offset ? `${city} · ${offset}` : city;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the list of items for an EntitySelect timezone picker. */
|
||||||
|
export function getTimezoneItems(): IconSelectItem[] {
|
||||||
|
const items: IconSelectItem[] = [];
|
||||||
|
const detected = _detectedTimezone();
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
value: '',
|
||||||
|
icon: ICON_TARGET_ICON,
|
||||||
|
label: t('common.daylight_tz.system') || 'System default',
|
||||||
|
desc: t('common.daylight_tz.system_desc') || 'Server clock',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detected) {
|
||||||
|
const offset = _utcOffsetLabel(detected);
|
||||||
|
const city = _friendlyCityFromIana(detected);
|
||||||
|
const detectedLabel = t('common.daylight_tz.detected_label') || 'Auto-detected';
|
||||||
|
items.push({
|
||||||
|
value: detected,
|
||||||
|
icon: ICON_SPARKLES,
|
||||||
|
label: _formatRowLabel(city, offset),
|
||||||
|
desc: `${detectedLabel} · ${detected}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tz of TIMEZONES) {
|
||||||
|
if (detected && tz.iana === detected) continue;
|
||||||
|
const offset = _utcOffsetLabel(tz.iana);
|
||||||
|
const region = _regionLabel(tz.region);
|
||||||
|
items.push({
|
||||||
|
value: tz.iana,
|
||||||
|
icon: tz.region === 'utc' ? ICON_CLOCK : ICON_GLOBE,
|
||||||
|
label: _formatRowLabel(tz.city, offset),
|
||||||
|
desc: `${region} · ${tz.iana}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate a `<select>` with timezone options. Kept for back-compat; the new
|
||||||
|
* preferred entrypoint is `enhanceTimezoneSelect()`.
|
||||||
|
*/
|
||||||
|
export function populateTimezoneSelect(selectId: string, currentValue: string): void {
|
||||||
|
const select = document.getElementById(selectId) as HTMLSelectElement | null;
|
||||||
|
if (!select) return;
|
||||||
|
_syncNativeOptions(select, currentValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _syncNativeOptions(select: HTMLSelectElement, currentValue: string): void {
|
||||||
|
const items = getTimezoneItems();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const fragments: string[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (seen.has(item.value)) continue;
|
||||||
|
seen.add(item.value);
|
||||||
|
const text = item.desc ? `${item.label} — ${item.desc}` : item.label;
|
||||||
|
fragments.push(`<option value="${_escapeAttr(item.value)}">${_escapeText(text)}</option>`);
|
||||||
|
}
|
||||||
|
if (currentValue && !seen.has(currentValue)) {
|
||||||
|
fragments.unshift(
|
||||||
|
`<option value="${_escapeAttr(currentValue)}">${_escapeText(currentValue)}</option>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
select.innerHTML = fragments.join('');
|
||||||
|
select.value = currentValue || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _escapeAttr(s: string): string {
|
||||||
|
return s.replace(/[&<>"']/g, c =>
|
||||||
|
c === '&' ? '&'
|
||||||
|
: c === '<' ? '<'
|
||||||
|
: c === '>' ? '>'
|
||||||
|
: c === '"' ? '"'
|
||||||
|
: ''',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function _escapeText(s: string): string {
|
||||||
|
return _escapeAttr(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _enhanced = new WeakMap<HTMLSelectElement, EntitySelect>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a native `<select>` with the refined timezone EntitySelect picker.
|
||||||
|
* Idempotent — calling on the same element again refreshes items and value.
|
||||||
|
*/
|
||||||
|
export function enhanceTimezoneSelect(
|
||||||
|
target: HTMLSelectElement,
|
||||||
|
currentValue: string,
|
||||||
|
onChange?: (value: string) => void,
|
||||||
|
): EntitySelect {
|
||||||
|
_syncNativeOptions(target, currentValue);
|
||||||
|
|
||||||
|
const existing = _enhanced.get(target);
|
||||||
|
if (existing) {
|
||||||
|
existing.refresh();
|
||||||
|
existing.setValue(currentValue || '');
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trigger = target.parentElement?.classList.contains('tz-picker-wrap');
|
||||||
|
if (trigger) {
|
||||||
|
// Hint for stylesheet: distinguish the timezone trigger from generic ones.
|
||||||
|
target.parentElement!.dataset.tzPicker = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const es = new EntitySelect({
|
||||||
|
target,
|
||||||
|
getItems: getTimezoneItems,
|
||||||
|
placeholder: t('common.daylight_tz.search') || 'Search timezones…',
|
||||||
|
onChange,
|
||||||
|
});
|
||||||
|
_enhanced.set(target, es);
|
||||||
|
return es;
|
||||||
|
}
|
||||||
@@ -92,7 +92,7 @@ const KIND_ICONS = {
|
|||||||
// ── Subtype-specific icon overrides ──
|
// ── Subtype-specific icon overrides ──
|
||||||
const SUBTYPE_ICONS = {
|
const SUBTYPE_ICONS = {
|
||||||
color_strip_source: {
|
color_strip_source: {
|
||||||
picture_advanced: P.monitor, static: P.palette, color_cycle: P.refreshCw,
|
picture_advanced: P.monitor, static: P.palette,
|
||||||
gradient: P.rainbow, effect: P.zap, composite: P.link,
|
gradient: P.rainbow, effect: P.zap, composite: P.link,
|
||||||
mapped: P.mapPin, mapped_zones: P.mapPin,
|
mapped: P.mapPin, mapped_zones: P.mapPin,
|
||||||
audio: P.music, audio_visualization: P.music,
|
audio: P.music, audio_visualization: P.music,
|
||||||
|
|||||||
@@ -124,3 +124,13 @@ export const chevronDown = '<path d="m6 9 6 6 6-6"/>';
|
|||||||
export const plus = '<path d="M5 12h14"/><path d="M12 5v14"/>';
|
export const plus = '<path d="M5 12h14"/><path d="M12 5v14"/>';
|
||||||
// Lucide: git-merge (sequence mode icon)
|
// Lucide: git-merge (sequence mode icon)
|
||||||
export const gitMerge = '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>';
|
export const gitMerge = '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>';
|
||||||
|
|
||||||
|
// Easing curve glyphs — custom mini-charts that draw the actual curve.
|
||||||
|
// Curve travels from (4, 20) to (20, 4); each path renders the easing
|
||||||
|
// function directly so the picker shows the shape, not a metaphor.
|
||||||
|
export const easingLinear = '<path d="M4 20 20 4"/>';
|
||||||
|
export const easingStep = '<path d="M4 20h8v-8h8V4"/>';
|
||||||
|
export const easingIn = '<path d="M4 20C13 20 16 18 20 4"/>';
|
||||||
|
export const easingOut = '<path d="M4 20C8 6 11 4 20 4"/>';
|
||||||
|
export const easingInOut = '<path d="M4 20C12 20 12 4 20 4"/>';
|
||||||
|
export const easingSine = '<path d="M4 12C7 12 8 4 12 4S17 12 20 12"/>';
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb
|
|||||||
const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image), video: _svg(P.film) };
|
const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image), video: _svg(P.film) };
|
||||||
const _colorStripTypeIcons = {
|
const _colorStripTypeIcons = {
|
||||||
picture_advanced: _svg(P.monitor),
|
picture_advanced: _svg(P.monitor),
|
||||||
static: _svg(P.palette), color_cycle: _svg(P.refreshCw), gradient: _svg(P.rainbow),
|
static: _svg(P.palette), gradient: _svg(P.rainbow),
|
||||||
effect: _svg(P.zap), composite: _svg(P.link),
|
effect: _svg(P.zap), composite: _svg(P.link),
|
||||||
mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin),
|
mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin),
|
||||||
audio: _svg(P.music), audio_visualization: _svg(P.music),
|
audio: _svg(P.music), audio_visualization: _svg(P.music),
|
||||||
|
|||||||
@@ -36,11 +36,14 @@ export class Modal {
|
|||||||
|
|
||||||
open() {
|
open() {
|
||||||
this._previousFocus = document.activeElement;
|
this._previousFocus = document.activeElement;
|
||||||
// Cancel any in-flight close animation so the modal becomes interactive again.
|
// If a close animation is in flight, we still own the body lock — it
|
||||||
|
// gets released in finalize(). Skip the lockBody() call in that case
|
||||||
|
// so the lock count stays balanced.
|
||||||
|
const hadInFlightClose = this._exitCleanup !== null;
|
||||||
this.el!.classList.remove('closing');
|
this.el!.classList.remove('closing');
|
||||||
this._cancelExitAnim();
|
this._cancelExitAnim();
|
||||||
this.el!.style.display = 'flex';
|
this.el!.style.display = 'flex';
|
||||||
if (this._lock) lockBody();
|
if (this._lock && !hadInFlightClose) lockBody();
|
||||||
if (this._backdrop) setupBackdropClose(this.el!, () => this.close());
|
if (this._backdrop) setupBackdropClose(this.el!, () => this.close());
|
||||||
trapFocus(this.el!);
|
trapFocus(this.el!);
|
||||||
Modal._stack = Modal._stack.filter(m => m !== this);
|
Modal._stack = Modal._stack.filter(m => m !== this);
|
||||||
@@ -72,12 +75,14 @@ export class Modal {
|
|||||||
if (this.el.classList.contains('closing')) return;
|
if (this.el.classList.contains('closing')) return;
|
||||||
|
|
||||||
// Modal-state cleanup happens immediately so subsequent code that
|
// Modal-state cleanup happens immediately so subsequent code that
|
||||||
// queries Modal._stack / focus / body-lock sees a "closed" state.
|
// queries Modal._stack / focus sees a "closed" state. Body-lock
|
||||||
|
// release AND subclass cleanup (onForceClose) are deferred to
|
||||||
|
// finalize() — running them now would toggle html.modal-open or
|
||||||
|
// mutate DOM (widget destruction clears innerHTML) before the exit
|
||||||
|
// animation, causing a visible layout shift inside the modal.
|
||||||
releaseFocus(this.el);
|
releaseFocus(this.el);
|
||||||
if (this._lock) unlockBody();
|
|
||||||
this._initialValues = {};
|
this._initialValues = {};
|
||||||
this.hideError();
|
this.hideError();
|
||||||
this.onForceClose();
|
|
||||||
Modal._stack = Modal._stack.filter(m => m !== this);
|
Modal._stack = Modal._stack.filter(m => m !== this);
|
||||||
if (this._previousFocus && typeof (this._previousFocus as HTMLElement).focus === 'function') {
|
if (this._previousFocus && typeof (this._previousFocus as HTMLElement).focus === 'function') {
|
||||||
(this._previousFocus as HTMLElement).focus({ preventScroll: true });
|
(this._previousFocus as HTMLElement).focus({ preventScroll: true });
|
||||||
@@ -95,10 +100,13 @@ export class Modal {
|
|||||||
const finalize = () => {
|
const finalize = () => {
|
||||||
this._exitCleanup = null;
|
this._exitCleanup = null;
|
||||||
// Guard against re-open: if open() was called during animation,
|
// Guard against re-open: if open() was called during animation,
|
||||||
// the .closing class is gone and display is already 'flex'.
|
// the .closing class is gone and display is already 'flex' — the
|
||||||
|
// body lock is still held by the re-opened modal, so don't release.
|
||||||
if (!el.classList.contains('closing')) return;
|
if (!el.classList.contains('closing')) return;
|
||||||
el.classList.remove('closing');
|
el.classList.remove('closing');
|
||||||
el.style.display = 'none';
|
el.style.display = 'none';
|
||||||
|
this.onForceClose();
|
||||||
|
if (this._lock) unlockBody();
|
||||||
};
|
};
|
||||||
|
|
||||||
let timer: number | null = null;
|
let timer: number | null = null;
|
||||||
@@ -110,6 +118,9 @@ export class Modal {
|
|||||||
this._exitCleanup = () => {
|
this._exitCleanup = () => {
|
||||||
if (timer !== null) { clearTimeout(timer); timer = null; }
|
if (timer !== null) { clearTimeout(timer); timer = null; }
|
||||||
content?.removeEventListener('animationend', onEnd);
|
content?.removeEventListener('animationend', onEnd);
|
||||||
|
// Re-open during animation: still run subclass cleanup so the
|
||||||
|
// fresh open re-initializes from a clean slate (widgets etc.).
|
||||||
|
this.onForceClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
el.classList.add('closing');
|
el.classList.add('closing');
|
||||||
@@ -156,7 +167,12 @@ export class Modal {
|
|||||||
if (this.errorEl) this.errorEl.style.display = 'none';
|
if (this.errorEl) this.errorEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hook for subclass cleanup on force-close (canvas, observers, etc.). */
|
/**
|
||||||
|
* Hook for subclass cleanup on force-close (canvas, observers, widget
|
||||||
|
* .destroy() calls, etc.). Runs AFTER the exit animation completes (or
|
||||||
|
* when an in-flight close is canceled by reopen), so DOM mutations
|
||||||
|
* here do not cause a layout shift visible during the animation.
|
||||||
|
*/
|
||||||
onForceClose() {}
|
onForceClose() {}
|
||||||
|
|
||||||
$(id: string) {
|
$(id: string) {
|
||||||
|
|||||||
@@ -290,6 +290,12 @@ export const captureTemplatesCache = new DataCache<CaptureTemplate[]>({
|
|||||||
});
|
});
|
||||||
captureTemplatesCache.subscribe(v => { _cachedCaptureTemplates = v; });
|
captureTemplatesCache.subscribe(v => { _cachedCaptureTemplates = v; });
|
||||||
|
|
||||||
|
export const enginesCache = new DataCache<EngineInfo[]>({
|
||||||
|
endpoint: '/capture-engines',
|
||||||
|
extractData: json => json.engines || [],
|
||||||
|
});
|
||||||
|
enginesCache.subscribe(v => { availableEngines = v; });
|
||||||
|
|
||||||
export const audioSourcesCache = new DataCache<AudioSource[]>({
|
export const audioSourcesCache = new DataCache<AudioSource[]>({
|
||||||
endpoint: '/audio-sources',
|
endpoint: '/audio-sources',
|
||||||
extractData: json => json.sources || [],
|
extractData: json => json.sources || [],
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function _gradientEntityStripHTML(stops: Array<{ position: number; color: number
|
|||||||
/* ── Non-picture types set ────────────────────────────────────── */
|
/* ── Non-picture types set ────────────────────────────────────── */
|
||||||
|
|
||||||
const NON_PICTURE_TYPES = new Set([
|
const NON_PICTURE_TYPES = new Set([
|
||||||
'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped',
|
'static', 'gradient', 'effect', 'composite', 'mapped',
|
||||||
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
|
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
|
||||||
'math_wave',
|
'math_wave',
|
||||||
]);
|
]);
|
||||||
@@ -65,16 +65,6 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
|||||||
${clockBadge}
|
${clockBadge}
|
||||||
`;
|
`;
|
||||||
},
|
},
|
||||||
color_cycle: (source, { clockBadge }) => {
|
|
||||||
const colors = source.colors || [];
|
|
||||||
const swatches = colors.slice(0, 8).map((c: any) =>
|
|
||||||
`<span style="display:inline-block;width:12px;height:12px;background:${rgbArrayToHex(c)};border:1px solid #888;border-radius:2px;margin-right:2px"></span>`
|
|
||||||
).join('');
|
|
||||||
return `
|
|
||||||
<span class="stream-card-prop">${swatches}</span>
|
|
||||||
${clockBadge}
|
|
||||||
`;
|
|
||||||
},
|
|
||||||
gradient: (source, { clockBadge, animBadge }) => {
|
gradient: (source, { clockBadge, animBadge }) => {
|
||||||
const stops = source.stops || [];
|
const stops = source.stops || [];
|
||||||
const sortedStops = [...stops].sort((a, b) => a.position - b.position);
|
const sortedStops = [...stops].sort((a, b) => a.position - b.position);
|
||||||
@@ -277,7 +267,6 @@ function _renderPictureCardProps(source: ColorStripSource, pictureSourceMap: Rec
|
|||||||
const STRIP_BADGE: Record<string, string> = {
|
const STRIP_BADGE: Record<string, string> = {
|
||||||
static: 'STRIP · COLOR',
|
static: 'STRIP · COLOR',
|
||||||
gradient: 'STRIP · GRD',
|
gradient: 'STRIP · GRD',
|
||||||
color_cycle: 'STRIP · CYCLE',
|
|
||||||
effect: 'STRIP · FX',
|
effect: 'STRIP · FX',
|
||||||
composite: 'STRIP · COMP',
|
composite: 'STRIP · COMP',
|
||||||
mapped: 'STRIP · MAP',
|
mapped: 'STRIP · MAP',
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
/**
|
|
||||||
* Color Strip Sources — Color Cycle helpers.
|
|
||||||
* Extracted from color-strips.ts to reduce file size.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ICON_TRASH } from '../../core/icons.ts';
|
|
||||||
import { rgbArrayToHex, hexToRgbArray } from '../css-gradient-editor.ts';
|
|
||||||
|
|
||||||
/* ── State ────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const _DEFAULT_CYCLE_COLORS = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff'];
|
|
||||||
let _colorCycleColors = [..._DEFAULT_CYCLE_COLORS];
|
|
||||||
|
|
||||||
/* ── DOM sync ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function _syncColorCycleFromDom() {
|
|
||||||
const inputs = document.querySelectorAll<HTMLInputElement>('#color-cycle-colors-list input[type=color]');
|
|
||||||
if (inputs.length > 0) {
|
|
||||||
_colorCycleColors = Array.from(inputs).map(el => el.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Rendering ────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function _colorCycleRenderList() {
|
|
||||||
const list = document.getElementById('color-cycle-colors-list') as HTMLElement | null;
|
|
||||||
if (!list) return;
|
|
||||||
const canRemove = _colorCycleColors.length > 2;
|
|
||||||
list.innerHTML = _colorCycleColors.map((hex, i) => `
|
|
||||||
<div class="color-cycle-item">
|
|
||||||
<input type="color" value="${hex}">
|
|
||||||
${canRemove
|
|
||||||
? `<button type="button" class="btn btn-secondary color-cycle-remove-btn"
|
|
||||||
onclick="colorCycleRemoveColor(${i})">${ICON_TRASH}</button>`
|
|
||||||
: `<div style="height:14px"></div>`}
|
|
||||||
</div>
|
|
||||||
`).join('') + `<div class="color-cycle-item"><button type="button" class="btn btn-secondary color-cycle-add-btn" onclick="colorCycleAddColor()">+</button></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Public actions ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function colorCycleAddColor() {
|
|
||||||
_syncColorCycleFromDom();
|
|
||||||
_colorCycleColors.push('#ffffff');
|
|
||||||
_colorCycleRenderList();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function colorCycleRemoveColor(i: number) {
|
|
||||||
_syncColorCycleFromDom();
|
|
||||||
if (_colorCycleColors.length <= 2) return;
|
|
||||||
_colorCycleColors.splice(i, 1);
|
|
||||||
_colorCycleRenderList();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function colorCycleGetColors() {
|
|
||||||
const inputs = document.querySelectorAll<HTMLInputElement>('#color-cycle-colors-list input[type=color]');
|
|
||||||
return Array.from(inputs).map(el => hexToRgbArray(el.value));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Load / Reset ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function loadColorCycleState(css: any) {
|
|
||||||
const raw = css && css.colors;
|
|
||||||
_colorCycleColors = (raw && raw.length >= 2)
|
|
||||||
? raw.map((c: any) => rgbArrayToHex(c))
|
|
||||||
: [..._DEFAULT_CYCLE_COLORS];
|
|
||||||
_colorCycleRenderList();
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Color Strip Sources — CRUD, editor orchestration, type switching.
|
* Color Strip Sources — CRUD, editor orchestration, type switching.
|
||||||
* Sub-modules handle type-specific logic: audio, cards, color-cycle, composite,
|
* Sub-modules handle type-specific logic: audio, cards, composite,
|
||||||
* game-event, gradient, mapped, math-wave, notification, test.
|
* game-event, gradient, mapped, math-wave, notification, test.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -62,9 +62,6 @@ import {
|
|||||||
mappedSetAvailableSources, mappedRenderList, mappedAddZone, mappedRemoveZone,
|
mappedSetAvailableSources, mappedRenderList, mappedAddZone, mappedRemoveZone,
|
||||||
mappedGetZones, loadMappedState, resetMappedState, getMappedZones,
|
mappedGetZones, loadMappedState, resetMappedState, getMappedZones,
|
||||||
} from './mapped.ts';
|
} from './mapped.ts';
|
||||||
import {
|
|
||||||
colorCycleAddColor, colorCycleRemoveColor, colorCycleGetColors, loadColorCycleState,
|
|
||||||
} from './color-cycle.ts';
|
|
||||||
import {
|
import {
|
||||||
destroyAudioWidgets, ensureAudioSensitivityWidget, ensureAudioSmoothingWidget,
|
destroyAudioWidgets, ensureAudioSensitivityWidget, ensureAudioSmoothingWidget,
|
||||||
ensureAudioBeatDecayWidget, ensureAudioColorWidget, ensureAudioColorPeakWidget,
|
ensureAudioBeatDecayWidget, ensureAudioColorWidget, ensureAudioColorPeakWidget,
|
||||||
@@ -90,11 +87,10 @@ export {
|
|||||||
notificationAddAppOverride, notificationRemoveAppOverride,
|
notificationAddAppOverride, notificationRemoveAppOverride,
|
||||||
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||||
};
|
};
|
||||||
export { _getAnimationPayload, colorCycleGetColors as _colorCycleGetColors };
|
export { _getAnimationPayload };
|
||||||
export { addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange };
|
export { addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange };
|
||||||
export { mathWaveAddLayer, mathWaveRemoveLayer };
|
export { mathWaveAddLayer, mathWaveRemoveLayer };
|
||||||
export { mappedAddZone, mappedRemoveZone };
|
export { mappedAddZone, mappedRemoveZone };
|
||||||
export { colorCycleAddColor, colorCycleRemoveColor };
|
|
||||||
export { createColorStripCard };
|
export { createColorStripCard };
|
||||||
export {
|
export {
|
||||||
onGradientPresetChange, promptAndSaveGradientPreset, deleteAndRefreshGradientPreset,
|
onGradientPresetChange, promptAndSaveGradientPreset, deleteAndRefreshGradientPreset,
|
||||||
@@ -158,7 +154,6 @@ class CSSEditorModal extends Modal {
|
|||||||
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
|
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
|
||||||
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
|
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
|
||||||
animation_type: (document.getElementById('css-editor-animation-type') as HTMLInputElement).value,
|
animation_type: (document.getElementById('css-editor-animation-type') as HTMLInputElement).value,
|
||||||
cycle_colors: JSON.stringify(colorCycleGetColors()),
|
|
||||||
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
|
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
|
||||||
effect_palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
|
effect_palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
|
||||||
effect_color: _effectColorWidget ? JSON.stringify(_effectColorWidget.getValue()) : '[]',
|
effect_color: _effectColorWidget ? JSON.stringify(_effectColorWidget.getValue()) : '[]',
|
||||||
@@ -190,6 +185,7 @@ class CSSEditorModal extends Modal {
|
|||||||
daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value,
|
daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value,
|
||||||
daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
|
daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
|
||||||
daylight_latitude: (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value,
|
daylight_latitude: (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value,
|
||||||
|
daylight_longitude: (document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value,
|
||||||
candlelight_color: _candlelightColorWidget ? JSON.stringify(_candlelightColorWidget.getValue()) : '[]',
|
candlelight_color: _candlelightColorWidget ? JSON.stringify(_candlelightColorWidget.getValue()) : '[]',
|
||||||
candlelight_intensity: _candlelightIntensityWidget ? JSON.stringify(_candlelightIntensityWidget.getValue()) : '1.0',
|
candlelight_intensity: _candlelightIntensityWidget ? JSON.stringify(_candlelightIntensityWidget.getValue()) : '1.0',
|
||||||
candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value,
|
candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value,
|
||||||
@@ -307,7 +303,7 @@ async function configureKCRegions(sourceId: string): Promise<void> {
|
|||||||
// ══════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const CSS_TYPE_KEYS = [
|
const CSS_TYPE_KEYS = [
|
||||||
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
|
'picture', 'picture_advanced', 'static', 'gradient',
|
||||||
'effect', 'composite', 'mapped', 'audio',
|
'effect', 'composite', 'mapped', 'audio',
|
||||||
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
|
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
|
||||||
'game_event', 'math_wave',
|
'game_event', 'math_wave',
|
||||||
@@ -346,7 +342,6 @@ const CSS_SECTION_MAP: Record<string, string> = {
|
|||||||
'picture': 'css-editor-picture-section',
|
'picture': 'css-editor-picture-section',
|
||||||
'picture_advanced': 'css-editor-picture-section',
|
'picture_advanced': 'css-editor-picture-section',
|
||||||
'static': 'css-editor-static-section',
|
'static': 'css-editor-static-section',
|
||||||
'color_cycle': 'css-editor-color-cycle-section',
|
|
||||||
'gradient': 'css-editor-gradient-section',
|
'gradient': 'css-editor-gradient-section',
|
||||||
'effect': 'css-editor-effect-section',
|
'effect': 'css-editor-effect-section',
|
||||||
'composite': 'css-editor-composite-section',
|
'composite': 'css-editor-composite-section',
|
||||||
@@ -422,7 +417,7 @@ export function onCSSTypeChange() {
|
|||||||
(document.getElementById('css-editor-led-count-group') as HTMLElement).style.display =
|
(document.getElementById('css-editor-led-count-group') as HTMLElement).style.display =
|
||||||
hasLedCount.includes(type) ? '' : 'none';
|
hasLedCount.includes(type) ? '' : 'none';
|
||||||
|
|
||||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
|
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
|
||||||
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
|
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
|
||||||
if (clockTypes.includes(type)) _populateClockDropdown();
|
if (clockTypes.includes(type)) _populateClockDropdown();
|
||||||
|
|
||||||
@@ -1006,15 +1001,6 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
return { name, color: _ensureStaticColorWidget().getValue(), animation: _getAnimationPayload() };
|
return { name, color: _ensureStaticColorWidget().getValue(), animation: _getAnimationPayload() };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
color_cycle: {
|
|
||||||
load(css) { loadColorCycleState(css); },
|
|
||||||
reset() { loadColorCycleState(null); },
|
|
||||||
getPayload(name) {
|
|
||||||
const cycleColors = colorCycleGetColors();
|
|
||||||
if (cycleColors.length < 2) { cssEditorModal.showError(t('color_strip.color_cycle.min_colors')); return null; }
|
|
||||||
return { name, colors: cycleColors };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
gradient: {
|
gradient: {
|
||||||
load(css) {
|
load(css) {
|
||||||
const gradientId = css.gradient_id || '';
|
const gradientId = css.gradient_id || '';
|
||||||
@@ -1180,6 +1166,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
(document.getElementById('css-editor-daylight-latitude-val') as HTMLElement).textContent = '50';
|
(document.getElementById('css-editor-daylight-latitude-val') as HTMLElement).textContent = '50';
|
||||||
(document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value = 0.0 as any;
|
(document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value = 0.0 as any;
|
||||||
(document.getElementById('css-editor-daylight-longitude-val') as HTMLElement).textContent = '0';
|
(document.getElementById('css-editor-daylight-longitude-val') as HTMLElement).textContent = '0';
|
||||||
|
_syncDaylightSpeedVisibility();
|
||||||
},
|
},
|
||||||
getPayload(name) {
|
getPayload(name) {
|
||||||
return {
|
return {
|
||||||
@@ -1563,7 +1550,7 @@ export async function saveCSSEditor() {
|
|||||||
|
|
||||||
payload.source_type = knownType ? sourceType : 'picture';
|
payload.source_type = knownType ? sourceType : 'picture';
|
||||||
|
|
||||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
|
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
|
||||||
if (clockTypes.includes(sourceType)) {
|
if (clockTypes.includes(sourceType)) {
|
||||||
payload.clock_id = (document.getElementById('css-editor-clock') as HTMLInputElement).value || null;
|
payload.clock_id = (document.getElementById('css-editor-clock') as HTMLInputElement).value || null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ import {
|
|||||||
} from '../../core/icons.ts';
|
} from '../../core/icons.ts';
|
||||||
import { EntitySelect } from '../../core/entity-palette.ts';
|
import { EntitySelect } from '../../core/entity-palette.ts';
|
||||||
import { hexToRgbArray, getGradientStops } from '../css-gradient-editor.ts';
|
import { hexToRgbArray, getGradientStops } from '../css-gradient-editor.ts';
|
||||||
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './index.ts';
|
import { testNotification, _getAnimationPayload } from './index.ts';
|
||||||
import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue, getNotificationVolumeValue } from './notification.ts';
|
import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue, getNotificationVolumeValue } from './notification.ts';
|
||||||
import { openAuthedWs } from '../../core/ws-auth.ts';
|
import { openAuthedWs } from '../../core/ws-auth.ts';
|
||||||
|
|
||||||
/* ── Preview config builder ───────────────────────────────────── */
|
/* ── Preview config builder ───────────────────────────────────── */
|
||||||
|
|
||||||
const _PREVIEW_TYPES = new Set([
|
const _PREVIEW_TYPES = new Set([
|
||||||
'static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'notification',
|
'static', 'gradient', 'effect', 'daylight', 'candlelight', 'notification',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function _collectPreviewConfig() {
|
function _collectPreviewConfig() {
|
||||||
@@ -35,10 +35,6 @@ function _collectPreviewConfig() {
|
|||||||
const stops = getGradientStops();
|
const stops = getGradientStops();
|
||||||
if (stops.length < 2) return null;
|
if (stops.length < 2) return null;
|
||||||
config = { source_type: 'gradient', stops: stops.map(s => ({ position: s.position, color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}) })), animation: _getAnimationPayload(), easing: (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value };
|
config = { source_type: 'gradient', stops: stops.map(s => ({ position: s.position, color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}) })), animation: _getAnimationPayload(), easing: (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value };
|
||||||
} else if (sourceType === 'color_cycle') {
|
|
||||||
const colors = _colorCycleGetColors();
|
|
||||||
if (colors.length < 2) return null;
|
|
||||||
config = { source_type: 'color_cycle', colors };
|
|
||||||
} else if (sourceType === 'effect') {
|
} else if (sourceType === 'effect') {
|
||||||
config = { source_type: 'effect', effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value), scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value), mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked };
|
config = { source_type: 'effect', effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value), scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value), mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked };
|
||||||
if (['meteor', 'comet', 'bouncing_ball'].includes(config.effect_type)) { const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
|
if (['meteor', 'comet', 'bouncing_ball'].includes(config.effect_type)) { const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
|
||||||
|
|||||||
@@ -323,9 +323,11 @@ function _renderIntegrationCard(conn: HomeAssistantConnectionStatus): string {
|
|||||||
? `${t('ha_source.connected')} — ${conn.entity_count} ${t('dashboard.integrations.entities')}`
|
? `${t('ha_source.connected')} — ${conn.entity_count} ${t('dashboard.integrations.entities')}`
|
||||||
: t('ha_source.disconnected');
|
: t('ha_source.disconnected');
|
||||||
const statusDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
|
const statusDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
|
||||||
|
const host = (conn.host || '').trim();
|
||||||
|
const entitiesPart = `${conn.entity_count} ${t('dashboard.integrations.entities')}`;
|
||||||
const subtitle = conn.connected
|
const subtitle = conn.connected
|
||||||
? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
|
? (host ? `${host} · ${entitiesPart}` : entitiesPart)
|
||||||
: t('ha_source.disconnected');
|
: (host ? `${host} · ${t('ha_source.disconnected')}` : t('ha_source.disconnected'));
|
||||||
const short = (conn.source_id || '').replace(/^src_/, '').slice(0, 2).toUpperCase() || 'HA';
|
const short = (conn.source_id || '').replace(/^src_/, '').slice(0, 2).toUpperCase() || 'HA';
|
||||||
const ledCls = conn.connected ? 'led on blink' : 'led';
|
const ledCls = conn.connected ? 'led on blink' : 'led';
|
||||||
const patchLabel = conn.connected ? 'ONLINE' : 'OFFLINE';
|
const patchLabel = conn.connected ? 'ONLINE' : 'OFFLINE';
|
||||||
@@ -403,9 +405,11 @@ function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttS
|
|||||||
}
|
}
|
||||||
const meta = card.querySelector('.mod-meta');
|
const meta = card.querySelector('.mod-meta');
|
||||||
if (meta) {
|
if (meta) {
|
||||||
|
const host = (conn.host || '').trim();
|
||||||
|
const entitiesPart = `${conn.entity_count} ${t('dashboard.integrations.entities')}`;
|
||||||
meta.textContent = conn.connected
|
meta.textContent = conn.connected
|
||||||
? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
|
? (host ? `${host} · ${entitiesPart}` : entitiesPart)
|
||||||
: t('ha_source.disconnected');
|
: (host ? `${host} · ${t('ha_source.disconnected')}` : t('ha_source.disconnected'));
|
||||||
}
|
}
|
||||||
applyState(card, conn.connected, conn.connected ? 'ONLINE' : 'OFFLINE');
|
applyState(card, conn.connected, conn.connected ? 'ONLINE' : 'OFFLINE');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,33 +25,61 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
|
|||||||
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
|
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
|
||||||
_pickerEngineType = engineType || null;
|
_pickerEngineType = engineType || null;
|
||||||
const lightbox = document.getElementById('display-picker-lightbox')!;
|
const lightbox = document.getElementById('display-picker-lightbox')!;
|
||||||
|
const canvas = document.getElementById('display-picker-canvas')!;
|
||||||
|
|
||||||
// Use "Select a Device" title for engines with own display lists (camera, scrcpy, etc.)
|
// Device-picker variants (camera, scrcpy) drop the big "Select Display"
|
||||||
const titleEl = lightbox.querySelector('.display-picker-title');
|
// title — the eyebrow channel strip already reads "Device · List", so
|
||||||
if (titleEl) {
|
// a redundant title rack just steals vertical space. The title only
|
||||||
titleEl.textContent = t(_pickerEngineType ? 'displays.picker.title.device' : 'displays.picker.title');
|
// renders in monitor-pick mode.
|
||||||
|
const isDevicePicker = !!_pickerEngineType;
|
||||||
|
const content = lightbox.querySelector('.display-picker-content');
|
||||||
|
content?.classList.toggle('display-picker-content--no-title', isDevicePicker);
|
||||||
|
|
||||||
|
const setI18n = (selector: string, key: string) => {
|
||||||
|
const el = lightbox.querySelector(selector);
|
||||||
|
if (!el) return;
|
||||||
|
el.setAttribute('data-i18n', key);
|
||||||
|
el.textContent = t(key);
|
||||||
|
};
|
||||||
|
if (!isDevicePicker) {
|
||||||
|
setI18n('.display-picker-title [data-role="lead"]', 'displays.picker.title.lead');
|
||||||
|
setI18n('.display-picker-title__accent', 'displays.picker.title.accent');
|
||||||
|
}
|
||||||
|
setI18n('.display-picker-eyebrow [data-role="label"]', 'displays.picker.eyebrow.label');
|
||||||
|
setI18n(
|
||||||
|
'.display-picker-eyebrow__channel',
|
||||||
|
isDevicePicker ? 'displays.picker.eyebrow.channel.device' : 'displays.picker.eyebrow.channel',
|
||||||
|
);
|
||||||
|
setI18n(
|
||||||
|
'.display-picker-foot [data-role="select"]',
|
||||||
|
isDevicePicker ? 'displays.picker.foot.select.device' : 'displays.picker.foot.select',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render synchronously *before* activating the modal so the first frame
|
||||||
|
// shows final content (or a spinner) instead of stale layout from the
|
||||||
|
// previous open.
|
||||||
|
if (_pickerEngineType) {
|
||||||
|
canvas.innerHTML = '<div class="loading-spinner"></div>';
|
||||||
|
} else if (_cachedDisplays && _cachedDisplays.length > 0) {
|
||||||
|
renderDisplayPickerLayout(_cachedDisplays);
|
||||||
|
} else {
|
||||||
|
canvas.innerHTML = '<div class="loading-spinner"></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
lightbox.classList.add('active');
|
lightbox.classList.add('active');
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
// Kick off async fetches after activation; spinner is already in place.
|
||||||
// Always fetch fresh when engine type is specified (different list each time)
|
if (_pickerEngineType) {
|
||||||
if (_pickerEngineType) {
|
_fetchAndRenderEngineDisplays(_pickerEngineType);
|
||||||
_fetchAndRenderEngineDisplays(_pickerEngineType);
|
} else if (!_cachedDisplays || _cachedDisplays.length === 0) {
|
||||||
} else if (_cachedDisplays && _cachedDisplays.length > 0) {
|
displaysCache.fetch().then(displays => {
|
||||||
renderDisplayPickerLayout(_cachedDisplays);
|
if (displays && displays.length > 0) {
|
||||||
} else {
|
renderDisplayPickerLayout(displays);
|
||||||
const canvas = document.getElementById('display-picker-canvas')!;
|
} else {
|
||||||
canvas.innerHTML = '<div class="loading-spinner"></div>';
|
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
|
||||||
displaysCache.fetch().then(displays => {
|
}
|
||||||
if (displays && displays.length > 0) {
|
});
|
||||||
renderDisplayPickerLayout(displays);
|
}
|
||||||
} else {
|
|
||||||
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void> {
|
async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void> {
|
||||||
|
|||||||
@@ -525,6 +525,7 @@ export function openSettingsModal(): void {
|
|||||||
loadBackupList();
|
loadBackupList();
|
||||||
loadLogLevel();
|
loadLogLevel();
|
||||||
loadShutdownAction();
|
loadShutdownAction();
|
||||||
|
loadDaylightTimezone();
|
||||||
_seedRailFooter();
|
_seedRailFooter();
|
||||||
// Refresh the update status so the rail badge ("update available" pill
|
// Refresh the update status so the rail badge ("update available" pill
|
||||||
// on the Updates tab) is current when the modal opens — it would
|
// on the Updates tab) is current when the modal opens — it would
|
||||||
@@ -975,6 +976,45 @@ export async function setShutdownAction(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Daylight timezone (global) ───────────────────────────────
|
||||||
|
|
||||||
|
export async function loadDaylightTimezone(): Promise<void> {
|
||||||
|
const select = document.getElementById('settings-daylight-timezone') as HTMLSelectElement | null;
|
||||||
|
if (!select) return;
|
||||||
|
let current = '';
|
||||||
|
try {
|
||||||
|
const resp = await fetchWithAuth('/preferences/daylight-timezone');
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
current = typeof data?.timezone === 'string' ? data.timezone : '';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load daylight timezone:', err);
|
||||||
|
}
|
||||||
|
const { enhanceTimezoneSelect } = await import('../core/daylight-tz.ts');
|
||||||
|
enhanceTimezoneSelect(select, current, () => { saveDaylightTimezone(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDaylightTimezone(): Promise<void> {
|
||||||
|
const select = document.getElementById('settings-daylight-timezone') as HTMLSelectElement | null;
|
||||||
|
if (!select) return;
|
||||||
|
const tz = select.value;
|
||||||
|
try {
|
||||||
|
const resp = await fetchWithAuth('/preferences/daylight-timezone', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ timezone: tz }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
showToast(t('settings.daylight_timezone.saved'), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save daylight timezone:', err);
|
||||||
|
showToast(t('settings.daylight_timezone.save_error') + ': ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Notifications tab ────────────────────────────────────────
|
// ─── Notifications tab ────────────────────────────────────────
|
||||||
|
|
||||||
const _NOTIF_EVENT_KEYS = [
|
const _NOTIF_EVENT_KEYS = [
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
availableEngines, setAvailableEngines,
|
availableEngines,
|
||||||
currentEditingTemplateId, setCurrentEditingTemplateId,
|
currentEditingTemplateId, setCurrentEditingTemplateId,
|
||||||
_templateNameManuallyEdited, set_templateNameManuallyEdited,
|
_templateNameManuallyEdited, set_templateNameManuallyEdited,
|
||||||
currentTestingTemplate, setCurrentTestingTemplate,
|
currentTestingTemplate, setCurrentTestingTemplate,
|
||||||
_cachedStreams, _cachedDisplays,
|
_cachedStreams, _cachedDisplays,
|
||||||
captureTemplatesCache, displaysCache,
|
captureTemplatesCache, displaysCache, enginesCache,
|
||||||
apiKey,
|
apiKey,
|
||||||
} from '../core/state.ts';
|
} from '../core/state.ts';
|
||||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||||
@@ -168,6 +168,7 @@ export async function showTestTemplateModal(templateId: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setCurrentTestingTemplate(template);
|
setCurrentTestingTemplate(template);
|
||||||
|
await enginesCache.fetch();
|
||||||
await loadDisplaysForTest();
|
await loadDisplaysForTest();
|
||||||
restoreCaptureDuration();
|
restoreCaptureDuration();
|
||||||
|
|
||||||
@@ -186,10 +187,7 @@ export function closeTestTemplateModal() {
|
|||||||
|
|
||||||
async function loadAvailableEngines() {
|
async function loadAvailableEngines() {
|
||||||
try {
|
try {
|
||||||
const response = await fetchWithAuth('/capture-engines');
|
await enginesCache.fetch();
|
||||||
if (!response.ok) throw new Error(`Failed to load engines: ${response.status}`);
|
|
||||||
const data = await response.json();
|
|
||||||
setAvailableEngines(data.engines || []);
|
|
||||||
|
|
||||||
const select = document.getElementById('template-engine') as HTMLSelectElement;
|
const select = document.getElementById('template-engine') as HTMLSelectElement;
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
@@ -224,6 +222,7 @@ async function loadAvailableEngines() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _engineIconSelect: IconSelect | null = null;
|
let _engineIconSelect: IconSelect | null = null;
|
||||||
|
const _configIconSelects: Map<string, IconSelect> = new Map();
|
||||||
|
|
||||||
export async function onEngineChange() {
|
export async function onEngineChange() {
|
||||||
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
|
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
|
||||||
@@ -249,16 +248,16 @@ export async function onEngineChange() {
|
|||||||
hint.style.display = 'none';
|
hint.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const inst of _configIconSelects.values()) inst.destroy();
|
||||||
|
_configIconSelects.clear();
|
||||||
configFields.innerHTML = '';
|
configFields.innerHTML = '';
|
||||||
const defaultConfig = engine.default_config || {};
|
const defaultConfig = engine.default_config || {};
|
||||||
|
const configChoices: Record<string, string[]> = engine.config_choices || {};
|
||||||
|
|
||||||
// Known select options for specific config keys
|
// Full catalogue of icon-select renderings for known enum-like config keys.
|
||||||
const CONFIG_SELECT_OPTIONS = {
|
// The actual list of options shown is intersected with engine.config_choices
|
||||||
camera_backend: ['auto', 'dshow', 'msmf', 'v4l2'],
|
// so platform-specific values (e.g. v4l2 on Windows) are hidden by the server.
|
||||||
};
|
const CONFIG_ICON_SELECT_CATALOGUE: Record<string, { columns: number; items: { value: string; icon: string; label: string; desc: string }[] }> = {
|
||||||
|
|
||||||
// IconSelect definitions for specific config keys
|
|
||||||
const CONFIG_ICON_SELECT = {
|
|
||||||
camera_backend: {
|
camera_backend: {
|
||||||
columns: 2,
|
columns: 2,
|
||||||
items: [
|
items: [
|
||||||
@@ -268,8 +267,31 @@ export async function onEngineChange() {
|
|||||||
{ value: 'v4l2', icon: _icon(P.monitor), label: 'V4L2', desc: t('templates.config.camera_backend.v4l2') },
|
{ value: 'v4l2', icon: _icon(P.monitor), label: 'V4L2', desc: t('templates.config.camera_backend.v4l2') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
resolution: {
|
||||||
|
columns: 2,
|
||||||
|
items: [
|
||||||
|
{ value: 'auto', icon: _icon(P.slidersHorizontal), label: 'Auto', desc: t('templates.config.resolution.auto') },
|
||||||
|
{ value: '640x480', icon: _icon(P.monitor), label: '640×480', desc: t('templates.config.resolution.480p') },
|
||||||
|
{ value: '1280x720', icon: _icon(P.monitor), label: '1280×720', desc: t('templates.config.resolution.720p') },
|
||||||
|
{ value: '1920x1080', icon: _icon(P.tv), label: '1920×1080', desc: t('templates.config.resolution.1080p') },
|
||||||
|
{ value: '2560x1440', icon: _icon(P.tv), label: '2560×1440', desc: t('templates.config.resolution.1440p') },
|
||||||
|
{ value: '3840x2160', icon: _icon(P.tv), label: '3840×2160', desc: t('templates.config.resolution.2160p') },
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CONFIG_SELECT_OPTIONS: Record<string, string[]> = {};
|
||||||
|
const CONFIG_ICON_SELECT: Record<string, { columns: number; items: { value: string; icon: string; label: string; desc: string }[] }> = {};
|
||||||
|
for (const [key, choices] of Object.entries(configChoices)) {
|
||||||
|
if (!choices || choices.length === 0) continue;
|
||||||
|
CONFIG_SELECT_OPTIONS[key] = choices;
|
||||||
|
const cat = CONFIG_ICON_SELECT_CATALOGUE[key];
|
||||||
|
if (cat) {
|
||||||
|
const filtered = cat.items.filter(item => choices.includes(item.value));
|
||||||
|
if (filtered.length > 0) CONFIG_ICON_SELECT[key] = { columns: cat.columns, items: filtered };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(defaultConfig).length === 0) {
|
if (Object.keys(defaultConfig).length === 0) {
|
||||||
configSection.style.display = 'none';
|
configSection.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
@@ -303,7 +325,10 @@ export async function onEngineChange() {
|
|||||||
// Apply IconSelect to known config selects
|
// Apply IconSelect to known config selects
|
||||||
for (const [key, cfg] of Object.entries(CONFIG_ICON_SELECT)) {
|
for (const [key, cfg] of Object.entries(CONFIG_ICON_SELECT)) {
|
||||||
const sel = document.getElementById(`config-${key}`);
|
const sel = document.getElementById(`config-${key}`);
|
||||||
if (sel) new IconSelect({ target: sel as HTMLSelectElement, items: cfg.items, columns: cfg.columns });
|
if (sel) {
|
||||||
|
const inst = new IconSelect({ target: sel as HTMLSelectElement, items: cfg.items, columns: cfg.columns });
|
||||||
|
_configIconSelects.set(key, inst);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +340,9 @@ function populateEngineConfig(config: any) {
|
|||||||
const field = document.getElementById(`config-${key}`) as HTMLInputElement | HTMLSelectElement | null;
|
const field = document.getElementById(`config-${key}`) as HTMLInputElement | HTMLSelectElement | null;
|
||||||
if (field) {
|
if (field) {
|
||||||
if (field.tagName === 'SELECT') {
|
if (field.tagName === 'SELECT') {
|
||||||
field.value = value.toString();
|
const iconSel = _configIconSelects.get(key);
|
||||||
|
if (iconSel) iconSel.setValue(value.toString());
|
||||||
|
else field.value = value.toString();
|
||||||
} else {
|
} else {
|
||||||
field.value = value;
|
field.value = value;
|
||||||
}
|
}
|
||||||
@@ -339,11 +366,41 @@ function collectEngineConfig() {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _engineHasOwnDisplays(engineType: string | undefined | null): boolean {
|
||||||
|
if (!engineType) return false;
|
||||||
|
return !!availableEngines.find(e => e.type === engineType)?.has_own_displays;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateTestPickerLabels() {
|
||||||
|
const engineType = currentTestingTemplate?.engine_type;
|
||||||
|
const useDevice = _engineHasOwnDisplays(engineType);
|
||||||
|
const labelEl = document.getElementById('test-template-display-label');
|
||||||
|
const pickerLabel = document.getElementById('test-display-picker-label');
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = t(useDevice ? 'templates.test.device' : 'templates.test.display');
|
||||||
|
}
|
||||||
|
// Only swap placeholder text when nothing has been selected yet
|
||||||
|
if (pickerLabel) {
|
||||||
|
const currentValue = (document.getElementById('test-template-display') as HTMLInputElement | null)?.value;
|
||||||
|
if (!currentValue) {
|
||||||
|
pickerLabel.textContent = t(useDevice ? 'templates.test.device.select' : 'displays.picker.select');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openTestDisplayPicker() {
|
||||||
|
const engineType = currentTestingTemplate?.engine_type || null;
|
||||||
|
const pickerEngineType = _engineHasOwnDisplays(engineType) ? engineType : null;
|
||||||
|
const currentValue = (document.getElementById('test-template-display') as HTMLInputElement).value;
|
||||||
|
openDisplayPicker((window as any).onTestDisplaySelected, currentValue, pickerEngineType);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDisplaysForTest() {
|
async function loadDisplaysForTest() {
|
||||||
try {
|
try {
|
||||||
// Use engine-specific display list for engines with own devices (camera, scrcpy)
|
// Use engine-specific display list for engines with own devices (camera, scrcpy)
|
||||||
const engineType = currentTestingTemplate?.engine_type;
|
const engineType = currentTestingTemplate?.engine_type;
|
||||||
const engineHasOwnDisplays = availableEngines.find(e => e.type === engineType)?.has_own_displays || false;
|
const engineHasOwnDisplays = _engineHasOwnDisplays(engineType);
|
||||||
|
_updateTestPickerLabels();
|
||||||
const url = engineHasOwnDisplays
|
const url = engineHasOwnDisplays
|
||||||
? `/config/displays?engine_type=${engineType}`
|
? `/config/displays?engine_type=${engineType}`
|
||||||
: '/config/displays';
|
: '/config/displays';
|
||||||
@@ -389,7 +446,10 @@ export function runTemplateTest() {
|
|||||||
const captureDuration = parseFloat((document.getElementById('test-template-duration') as HTMLInputElement).value);
|
const captureDuration = parseFloat((document.getElementById('test-template-duration') as HTMLInputElement).value);
|
||||||
|
|
||||||
if (displayIndex === '') {
|
if (displayIndex === '') {
|
||||||
showToast(t('templates.test.error.no_display'), 'error');
|
const errKey = _engineHasOwnDisplays(currentTestingTemplate.engine_type)
|
||||||
|
? 'templates.test.error.no_device'
|
||||||
|
: 'templates.test.error.no_display';
|
||||||
|
showToast(t(errKey), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
_cachedPPTemplates,
|
_cachedPPTemplates,
|
||||||
_cachedCaptureTemplates,
|
_cachedCaptureTemplates,
|
||||||
_availableFilters,
|
_availableFilters,
|
||||||
availableEngines, setAvailableEngines,
|
availableEngines,
|
||||||
currentEditingTemplateId, setCurrentEditingTemplateId,
|
currentEditingTemplateId, setCurrentEditingTemplateId,
|
||||||
_templateNameManuallyEdited, set_templateNameManuallyEdited,
|
_templateNameManuallyEdited, set_templateNameManuallyEdited,
|
||||||
_streamNameManuallyEdited, set_streamNameManuallyEdited,
|
_streamNameManuallyEdited, set_streamNameManuallyEdited,
|
||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
|
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
|
||||||
_sourcesLoading, set_sourcesLoading,
|
_sourcesLoading, set_sourcesLoading,
|
||||||
apiKey,
|
apiKey,
|
||||||
streamsCache, ppTemplatesCache, captureTemplatesCache,
|
streamsCache, ppTemplatesCache, captureTemplatesCache, enginesCache,
|
||||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, assetsCache, _cachedAssets, filtersCache,
|
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, assetsCache, _cachedAssets, filtersCache,
|
||||||
colorStripSourcesCache,
|
colorStripSourcesCache,
|
||||||
csptCache, stripFiltersCache,
|
csptCache, stripFiltersCache,
|
||||||
@@ -259,7 +259,7 @@ import { showAddTemplateModal as _localShowAddTemplateModal, _runTestViaWS as _l
|
|||||||
export {
|
export {
|
||||||
showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate,
|
showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate,
|
||||||
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest,
|
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest,
|
||||||
updateCaptureDuration, _runTestViaWS,
|
updateCaptureDuration, _runTestViaWS, openTestDisplayPicker,
|
||||||
} from './streams-capture-templates.ts';
|
} from './streams-capture-templates.ts';
|
||||||
|
|
||||||
// ── Audio Templates (extracted to streams-audio-templates.ts) ──
|
// ── Audio Templates (extracted to streams-audio-templates.ts) ──
|
||||||
@@ -1166,7 +1166,9 @@ export async function editStream(streamId: any) {
|
|||||||
await populateStreamModalDropdowns();
|
await populateStreamModalDropdowns();
|
||||||
|
|
||||||
if (stream.stream_type === 'raw') {
|
if (stream.stream_type === 'raw') {
|
||||||
(document.getElementById('stream-capture-template') as HTMLSelectElement).value = stream.capture_template_id || '';
|
const tplId = stream.capture_template_id || '';
|
||||||
|
(document.getElementById('stream-capture-template') as HTMLSelectElement).value = tplId;
|
||||||
|
if (_captureTemplateEntitySelect) _captureTemplateEntitySelect.setValue(tplId);
|
||||||
// Ensure correct engine displays are loaded for this template
|
// Ensure correct engine displays are loaded for this template
|
||||||
await _onCaptureTemplateChanged();
|
await _onCaptureTemplateChanged();
|
||||||
const displayIdx = stream.display_index ?? 0;
|
const displayIdx = stream.display_index ?? 0;
|
||||||
@@ -1176,8 +1178,12 @@ export async function editStream(streamId: any) {
|
|||||||
(document.getElementById('stream-target-fps') as HTMLInputElement).value = fps;
|
(document.getElementById('stream-target-fps') as HTMLInputElement).value = fps;
|
||||||
document.getElementById('stream-target-fps-value')!.textContent = fps;
|
document.getElementById('stream-target-fps-value')!.textContent = fps;
|
||||||
} else if (stream.stream_type === 'processed') {
|
} else if (stream.stream_type === 'processed') {
|
||||||
(document.getElementById('stream-source') as HTMLSelectElement).value = stream.source_stream_id || '';
|
const srcId = stream.source_stream_id || '';
|
||||||
(document.getElementById('stream-pp-template') as HTMLSelectElement).value = stream.postprocessing_template_id || '';
|
const ppId = stream.postprocessing_template_id || '';
|
||||||
|
(document.getElementById('stream-source') as HTMLSelectElement).value = srcId;
|
||||||
|
if (_sourceEntitySelect) _sourceEntitySelect.setValue(srcId);
|
||||||
|
(document.getElementById('stream-pp-template') as HTMLSelectElement).value = ppId;
|
||||||
|
if (_ppTemplateEntitySelect) _ppTemplateEntitySelect.setValue(ppId);
|
||||||
} else if (stream.stream_type === 'static_image') {
|
} else if (stream.stream_type === 'static_image') {
|
||||||
if (stream.image_asset_id) {
|
if (stream.image_asset_id) {
|
||||||
(document.getElementById('stream-image-asset') as HTMLSelectElement).value = stream.image_asset_id;
|
(document.getElementById('stream-image-asset') as HTMLSelectElement).value = stream.image_asset_id;
|
||||||
@@ -1227,6 +1233,7 @@ async function populateStreamModalDropdowns() {
|
|||||||
streamsCache.fetch().catch((): any[] => []),
|
streamsCache.fetch().catch((): any[] => []),
|
||||||
ppTemplatesCache.fetch().catch((): any[] => []),
|
ppTemplatesCache.fetch().catch((): any[] => []),
|
||||||
displaysCache.fetch().catch((): any[] => []),
|
displaysCache.fetch().catch((): any[] => []),
|
||||||
|
enginesCache.fetch().catch((): any[] => []),
|
||||||
]);
|
]);
|
||||||
_streamModalDisplaysEngine = null;
|
_streamModalDisplaysEngine = null;
|
||||||
|
|
||||||
|
|||||||
@@ -523,26 +523,6 @@ function _formatPublished(iso: string | null | undefined): string | null {
|
|||||||
function _renderReleaseNotesHeader(): void {
|
function _renderReleaseNotesHeader(): void {
|
||||||
const release = _lastStatus?.release ?? null;
|
const release = _lastStatus?.release ?? null;
|
||||||
|
|
||||||
const name = release?.name?.trim() || '';
|
|
||||||
const version = release?.version?.trim() || '';
|
|
||||||
const nameEl = document.getElementById('release-notes-name');
|
|
||||||
if (nameEl) {
|
|
||||||
nameEl.textContent = name || t('update.release_notes');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show the version accent when the name doesn't already contain
|
|
||||||
// the version — avoids rendering "LedGrab v0.5.0 v0.5.0".
|
|
||||||
const nameLower = name.toLowerCase();
|
|
||||||
const verLower = version.toLowerCase();
|
|
||||||
const nameHasVersion = !!verLower && (
|
|
||||||
nameLower.includes(verLower) || nameLower.includes('v' + verLower)
|
|
||||||
);
|
|
||||||
if (version && !nameHasVersion) {
|
|
||||||
_setRnText('release-notes-version', `v${version}`);
|
|
||||||
} else {
|
|
||||||
_setRnText('release-notes-version', null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tag = release?.tag?.trim() || null;
|
const tag = release?.tag?.trim() || null;
|
||||||
_setRnText('release-notes-tag', tag);
|
_setRnText('release-notes-tag', tag);
|
||||||
_setRnChipShown('release-notes-tag-chip', !!tag);
|
_setRnChipShown('release-notes-tag-chip', !!tag);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
_cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache,
|
_cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache,
|
||||||
_cachedHASources, _cachedColorStripSources, gradientsCache, GradientEntity,
|
_cachedHASources, _cachedColorStripSources, gradientsCache, GradientEntity,
|
||||||
_cachedGameIntegrations, _cachedGameAdapters, gameIntegrationsCache, gameAdaptersCache,
|
_cachedGameIntegrations, _cachedGameAdapters, gameIntegrationsCache, gameAdaptersCache,
|
||||||
|
_cachedSyncClocks, syncClocksCache,
|
||||||
} from '../core/state.ts';
|
} from '../core/state.ts';
|
||||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
@@ -24,7 +25,7 @@ import {
|
|||||||
ICON_CLONE, ICON_EDIT, ICON_TEST,
|
ICON_CLONE, ICON_EDIT, ICON_TEST,
|
||||||
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK,
|
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK,
|
||||||
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, ICON_TRASH,
|
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, ICON_TRASH,
|
||||||
ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS, ICON_GAMEPAD,
|
ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS, ICON_GAMEPAD, ICON_X,
|
||||||
} from '../core/icons.ts';
|
} from '../core/icons.ts';
|
||||||
import { wrapCard } from '../core/card-colors.ts';
|
import { wrapCard } from '../core/card-colors.ts';
|
||||||
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
|
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
|
||||||
@@ -52,6 +53,7 @@ let _vsCSSSourceEntitySelect: EntitySelect | null = null;
|
|||||||
let _vsGradientEasingIconSelect: IconSelect | null = null;
|
let _vsGradientEasingIconSelect: IconSelect | null = null;
|
||||||
let _vsBehaviorIconSelect: IconSelect | null = null;
|
let _vsBehaviorIconSelect: IconSelect | null = null;
|
||||||
let _vsMetricIconSelect: IconSelect | null = null;
|
let _vsMetricIconSelect: IconSelect | null = null;
|
||||||
|
let _vsAnimColorClockEntitySelect: EntitySelect | null = null;
|
||||||
let _vsTagsInput: TagInput | null = null;
|
let _vsTagsInput: TagInput | null = null;
|
||||||
|
|
||||||
class ValueSourceModal extends Modal {
|
class ValueSourceModal extends Modal {
|
||||||
@@ -68,6 +70,7 @@ class ValueSourceModal extends Modal {
|
|||||||
if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; }
|
if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; }
|
||||||
if (_vsBehaviorIconSelect) { _vsBehaviorIconSelect.destroy(); _vsBehaviorIconSelect = null; }
|
if (_vsBehaviorIconSelect) { _vsBehaviorIconSelect.destroy(); _vsBehaviorIconSelect = null; }
|
||||||
if (_vsMetricIconSelect) { _vsMetricIconSelect.destroy(); _vsMetricIconSelect = null; }
|
if (_vsMetricIconSelect) { _vsMetricIconSelect.destroy(); _vsMetricIconSelect = null; }
|
||||||
|
if (_vsAnimColorClockEntitySelect) { _vsAnimColorClockEntitySelect.destroy(); _vsAnimColorClockEntitySelect = null; }
|
||||||
if (_vsGameIntegrationEntitySelect) { _vsGameIntegrationEntitySelect.destroy(); _vsGameIntegrationEntitySelect = null; }
|
if (_vsGameIntegrationEntitySelect) { _vsGameIntegrationEntitySelect.destroy(); _vsGameIntegrationEntitySelect = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,10 +100,12 @@ class ValueSourceModal extends Modal {
|
|||||||
daylightSpeed: (document.getElementById('value-source-daylight-speed') as HTMLInputElement).value,
|
daylightSpeed: (document.getElementById('value-source-daylight-speed') as HTMLInputElement).value,
|
||||||
daylightRealTime: (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked,
|
daylightRealTime: (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked,
|
||||||
daylightLatitude: (document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value,
|
daylightLatitude: (document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value,
|
||||||
|
daylightLongitude: (document.getElementById('value-source-daylight-longitude') as HTMLInputElement).value,
|
||||||
staticColor: (document.getElementById('value-source-static-color') as HTMLInputElement).value,
|
staticColor: (document.getElementById('value-source-static-color') as HTMLInputElement).value,
|
||||||
animatedColors: JSON.stringify(_animatedColors),
|
animatedColors: JSON.stringify(_animatedColors),
|
||||||
animatedColorSpeed: (document.getElementById('value-source-animated-color-speed') as HTMLInputElement).value,
|
animatedColorSpeed: (document.getElementById('value-source-animated-color-speed') as HTMLInputElement).value,
|
||||||
animatedColorEasing: (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value,
|
animatedColorEasing: (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value,
|
||||||
|
animatedColorClock: (document.getElementById('value-source-animated-color-clock') as HTMLSelectElement)?.value || '',
|
||||||
colorSchedule: JSON.stringify(_colorSchedulePoints),
|
colorSchedule: JSON.stringify(_colorSchedulePoints),
|
||||||
tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []),
|
tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []),
|
||||||
metric: (document.getElementById('value-source-metric') as HTMLSelectElement).value,
|
metric: (document.getElementById('value-source-metric') as HTMLSelectElement).value,
|
||||||
@@ -200,11 +205,15 @@ function _ensureColorEasingIconSelect() {
|
|||||||
const sel = document.getElementById('value-source-animated-color-easing') as HTMLSelectElement | null;
|
const sel = document.getElementById('value-source-animated-color-easing') as HTMLSelectElement | null;
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const items = [
|
const items = [
|
||||||
{ value: 'linear', icon: _icon(P.activity), label: t('value_source.animated_color.easing.linear'), desc: t('value_source.animated_color.easing.linear.desc') },
|
{ value: 'linear', icon: _icon(P.easingLinear), label: t('value_source.animated_color.easing.linear'), desc: t('value_source.animated_color.easing.linear.desc') },
|
||||||
{ value: 'step', icon: _icon(P.layoutDashboard), label: t('value_source.animated_color.easing.step'), desc: t('value_source.animated_color.easing.step.desc') },
|
{ value: 'step', icon: _icon(P.easingStep), label: t('value_source.animated_color.easing.step'), desc: t('value_source.animated_color.easing.step.desc') },
|
||||||
|
{ value: 'ease_in', icon: _icon(P.easingIn), label: t('value_source.animated_color.easing.ease_in'), desc: t('value_source.animated_color.easing.ease_in.desc') },
|
||||||
|
{ value: 'ease_out', icon: _icon(P.easingOut), label: t('value_source.animated_color.easing.ease_out'), desc: t('value_source.animated_color.easing.ease_out.desc') },
|
||||||
|
{ value: 'ease_in_out', icon: _icon(P.easingInOut), label: t('value_source.animated_color.easing.ease_in_out'), desc: t('value_source.animated_color.easing.ease_in_out.desc') },
|
||||||
|
{ value: 'sine', icon: _icon(P.easingSine), label: t('value_source.animated_color.easing.sine'), desc: t('value_source.animated_color.easing.sine.desc') },
|
||||||
];
|
];
|
||||||
if (_vsColorEasingIconSelect) { _vsColorEasingIconSelect.updateItems(items); return; }
|
if (_vsColorEasingIconSelect) { _vsColorEasingIconSelect.updateItems(items); return; }
|
||||||
_vsColorEasingIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
|
_vsColorEasingIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Waveform canvas preview ──────────────────────────────────── */
|
/* ── Waveform canvas preview ──────────────────────────────────── */
|
||||||
@@ -500,6 +509,7 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
|||||||
_setSlider('value-source-daylight-speed', editData.speed ?? 1.0);
|
_setSlider('value-source-daylight-speed', editData.speed ?? 1.0);
|
||||||
(document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked = !!editData.use_real_time;
|
(document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked = !!editData.use_real_time;
|
||||||
_setSlider('value-source-daylight-latitude', editData.latitude ?? 50);
|
_setSlider('value-source-daylight-latitude', editData.latitude ?? 50);
|
||||||
|
_setSlider('value-source-daylight-longitude', editData.longitude ?? 0);
|
||||||
_syncDaylightVSSpeedVisibility();
|
_syncDaylightVSSpeedVisibility();
|
||||||
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
|
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
|
||||||
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
|
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
|
||||||
@@ -512,6 +522,8 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
|||||||
_setSlider('value-source-animated-color-speed', editData.speed ?? 10.0);
|
_setSlider('value-source-animated-color-speed', editData.speed ?? 10.0);
|
||||||
(document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value = editData.easing || 'linear';
|
(document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value = editData.easing || 'linear';
|
||||||
_ensureColorEasingIconSelect();
|
_ensureColorEasingIconSelect();
|
||||||
|
await syncClocksCache.fetch();
|
||||||
|
_populateAnimatedColorClockDropdown(editData.clock_id || '');
|
||||||
} else if (editData.source_type === 'adaptive_time_color') {
|
} else if (editData.source_type === 'adaptive_time_color') {
|
||||||
_colorSchedulePoints = (editData.schedule || []).map((p: any) => ({
|
_colorSchedulePoints = (editData.schedule || []).map((p: any) => ({
|
||||||
time: p.time,
|
time: p.time,
|
||||||
@@ -586,6 +598,7 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
|||||||
_setSlider('value-source-daylight-speed', 1.0);
|
_setSlider('value-source-daylight-speed', 1.0);
|
||||||
(document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked = false;
|
(document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked = false;
|
||||||
_setSlider('value-source-daylight-latitude', 50);
|
_setSlider('value-source-daylight-latitude', 50);
|
||||||
|
_setSlider('value-source-daylight-longitude', 0);
|
||||||
_syncDaylightVSSpeedVisibility();
|
_syncDaylightVSSpeedVisibility();
|
||||||
// Color type defaults
|
// Color type defaults
|
||||||
(document.getElementById('value-source-static-color') as HTMLInputElement).value = '#ffffff';
|
(document.getElementById('value-source-static-color') as HTMLInputElement).value = '#ffffff';
|
||||||
@@ -593,6 +606,8 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
|||||||
_renderAnimatedColorList();
|
_renderAnimatedColorList();
|
||||||
_setSlider('value-source-animated-color-speed', 10.0);
|
_setSlider('value-source-animated-color-speed', 10.0);
|
||||||
(document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value = 'linear';
|
(document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value = 'linear';
|
||||||
|
await syncClocksCache.fetch();
|
||||||
|
_populateAnimatedColorClockDropdown('');
|
||||||
_colorSchedulePoints = [];
|
_colorSchedulePoints = [];
|
||||||
_renderColorScheduleList();
|
_renderColorScheduleList();
|
||||||
// HA entity defaults
|
// HA entity defaults
|
||||||
@@ -768,6 +783,7 @@ export async function saveValueSource() {
|
|||||||
payload.speed = parseFloat((document.getElementById('value-source-daylight-speed') as HTMLInputElement).value);
|
payload.speed = parseFloat((document.getElementById('value-source-daylight-speed') as HTMLInputElement).value);
|
||||||
payload.use_real_time = (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked;
|
payload.use_real_time = (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked;
|
||||||
payload.latitude = parseFloat((document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value);
|
payload.latitude = parseFloat((document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value);
|
||||||
|
payload.longitude = parseFloat((document.getElementById('value-source-daylight-longitude') as HTMLInputElement).value);
|
||||||
payload.min_value = parseFloat((document.getElementById('value-source-adaptive-min-value') as HTMLInputElement).value);
|
payload.min_value = parseFloat((document.getElementById('value-source-adaptive-min-value') as HTMLInputElement).value);
|
||||||
payload.max_value = parseFloat((document.getElementById('value-source-adaptive-max-value') as HTMLInputElement).value);
|
payload.max_value = parseFloat((document.getElementById('value-source-adaptive-max-value') as HTMLInputElement).value);
|
||||||
} else if (sourceType === 'static_color') {
|
} else if (sourceType === 'static_color') {
|
||||||
@@ -776,6 +792,8 @@ export async function saveValueSource() {
|
|||||||
payload.colors = _getAnimatedColorsPayload();
|
payload.colors = _getAnimatedColorsPayload();
|
||||||
payload.speed = parseFloat((document.getElementById('value-source-animated-color-speed') as HTMLInputElement).value);
|
payload.speed = parseFloat((document.getElementById('value-source-animated-color-speed') as HTMLInputElement).value);
|
||||||
payload.easing = (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value;
|
payload.easing = (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value;
|
||||||
|
// Send empty string to clear (resolve_ref pattern); a real ID otherwise.
|
||||||
|
payload.clock_id = (document.getElementById('value-source-animated-color-clock') as HTMLSelectElement).value || '';
|
||||||
} else if (sourceType === 'adaptive_time_color') {
|
} else if (sourceType === 'adaptive_time_color') {
|
||||||
payload.schedule = _getColorSchedulePayload();
|
payload.schedule = _getColorSchedulePayload();
|
||||||
} else if (sourceType === 'ha_entity') {
|
} else if (sourceType === 'ha_entity') {
|
||||||
@@ -1575,20 +1593,36 @@ export function removeAnimatedColor(idx: number) {
|
|||||||
function _renderAnimatedColorList() {
|
function _renderAnimatedColorList() {
|
||||||
const list = document.getElementById('value-source-animated-color-list');
|
const list = document.getElementById('value-source-animated-color-list');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
list.innerHTML = _animatedColors.map((c, i) => `
|
const canRemove = _animatedColors.length > 1;
|
||||||
<div class="schedule-row" style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
const chips = _animatedColors.map((c, i) => `
|
||||||
|
<div class="animated-color-chip" role="listitem" style="--chip-color:${c}">
|
||||||
<input type="color" class="animated-color-input" value="${c}" data-idx="${i}"
|
<input type="color" class="animated-color-input" value="${c}" data-idx="${i}"
|
||||||
onchange="_animatedColors[${i}] = this.value">
|
aria-label="Color ${i + 1}">
|
||||||
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeAnimatedColor(${i})">${ICON_TRASH}</button>
|
${canRemove ? `
|
||||||
|
<button type="button" class="animated-color-remove"
|
||||||
|
onclick="removeAnimatedColor(${i})"
|
||||||
|
aria-label="Remove color ${i + 1}">${ICON_X}</button>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
// Wire up color inputs to update state immutably
|
const addBtn = `
|
||||||
|
<button type="button" class="animated-color-add" onclick="addAnimatedColor()"
|
||||||
|
aria-label="Add color">
|
||||||
|
<span class="animated-color-add-glyph">+</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
list.innerHTML = chips + addBtn;
|
||||||
|
// Wire up color inputs to update state immutably and refresh the chip's CSS variable
|
||||||
list.querySelectorAll('.animated-color-input').forEach((el) => {
|
list.querySelectorAll('.animated-color-input').forEach((el) => {
|
||||||
const input = el as HTMLInputElement;
|
const input = el as HTMLInputElement;
|
||||||
const idx = parseInt(input.dataset.idx || '0', 10);
|
const idx = parseInt(input.dataset.idx || '0', 10);
|
||||||
input.addEventListener('input', () => {
|
const chip = input.closest('.animated-color-chip') as HTMLElement | null;
|
||||||
|
const onChange = () => {
|
||||||
_animatedColors = _animatedColors.map((c, i) => i === idx ? input.value : c);
|
_animatedColors = _animatedColors.map((c, i) => i === idx ? input.value : c);
|
||||||
});
|
if (chip) chip.style.setProperty('--chip-color', input.value);
|
||||||
|
};
|
||||||
|
input.addEventListener('input', onChange);
|
||||||
|
input.addEventListener('change', onChange);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1596,6 +1630,28 @@ function _getAnimatedColorsPayload(): number[][] {
|
|||||||
return _animatedColors.map(c => hexToRgbArray(c));
|
return _animatedColors.map(c => hexToRgbArray(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _populateAnimatedColorClockDropdown(selectedId: string) {
|
||||||
|
const sel = document.getElementById('value-source-animated-color-clock') as HTMLSelectElement | null;
|
||||||
|
if (!sel) return;
|
||||||
|
sel.innerHTML = `<option value="">${t('common.none_own_speed')}</option>` +
|
||||||
|
_cachedSyncClocks.map(c => `<option value="${c.id}">${escapeHtml(c.name)} (${c.speed}x)</option>`).join('');
|
||||||
|
sel.value = selectedId || '';
|
||||||
|
|
||||||
|
if (_vsAnimColorClockEntitySelect) { _vsAnimColorClockEntitySelect.destroy(); _vsAnimColorClockEntitySelect = null; }
|
||||||
|
_vsAnimColorClockEntitySelect = new EntitySelect({
|
||||||
|
target: sel,
|
||||||
|
getItems: () => _cachedSyncClocks.map(c => ({
|
||||||
|
value: c.id,
|
||||||
|
label: c.name,
|
||||||
|
icon: ICON_CLOCK,
|
||||||
|
desc: `${c.speed}x`,
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
allowNone: true,
|
||||||
|
noneLabel: t('common.none_own_speed'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Color Schedule helpers ──────────────────────────────────
|
// ── Color Schedule helpers ──────────────────────────────────
|
||||||
|
|
||||||
let _colorSchedulePoints: { time: string; color: string }[] = [];
|
let _colorSchedulePoints: { time: string; color: string }[] = [];
|
||||||
|
|||||||
+1
-2
@@ -118,6 +118,7 @@ interface Window {
|
|||||||
closeTestTemplateModal: (...args: any[]) => any;
|
closeTestTemplateModal: (...args: any[]) => any;
|
||||||
onEngineChange: (...args: any[]) => any;
|
onEngineChange: (...args: any[]) => any;
|
||||||
runTemplateTest: (...args: any[]) => any;
|
runTemplateTest: (...args: any[]) => any;
|
||||||
|
openTestDisplayPicker: (...args: any[]) => any;
|
||||||
updateCaptureDuration: (...args: any[]) => any;
|
updateCaptureDuration: (...args: any[]) => any;
|
||||||
showAddStreamModal: (...args: any[]) => any;
|
showAddStreamModal: (...args: any[]) => any;
|
||||||
editStream: (...args: any[]) => any;
|
editStream: (...args: any[]) => any;
|
||||||
@@ -262,8 +263,6 @@ startTargetOverlay: (...args: any[]) => any;
|
|||||||
onCSSClockChange: (...args: any[]) => any;
|
onCSSClockChange: (...args: any[]) => any;
|
||||||
onAnimationTypeChange: (...args: any[]) => any;
|
onAnimationTypeChange: (...args: any[]) => any;
|
||||||
onDaylightRealTimeChange: (...args: any[]) => any;
|
onDaylightRealTimeChange: (...args: any[]) => any;
|
||||||
colorCycleAddColor: (...args: any[]) => any;
|
|
||||||
colorCycleRemoveColor: (...args: any[]) => any;
|
|
||||||
compositeAddLayer: (...args: any[]) => any;
|
compositeAddLayer: (...args: any[]) => any;
|
||||||
compositeRemoveLayer: (...args: any[]) => any;
|
compositeRemoveLayer: (...args: any[]) => any;
|
||||||
mappedAddZone: (...args: any[]) => any;
|
mappedAddZone: (...args: any[]) => any;
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export type OutputTarget = LedOutputTarget | HALightOutputTarget;
|
|||||||
|
|
||||||
export type CSSSourceType =
|
export type CSSSourceType =
|
||||||
| 'picture' | 'picture_advanced' | 'static' | 'gradient'
|
| 'picture' | 'picture_advanced' | 'static' | 'gradient'
|
||||||
| 'color_cycle' | 'effect' | 'composite' | 'mapped'
|
| 'effect' | 'composite' | 'mapped'
|
||||||
| 'audio' | 'api_input' | 'notification' | 'daylight'
|
| 'audio' | 'api_input' | 'notification' | 'daylight'
|
||||||
| 'candlelight' | 'processed' | 'weather' | 'key_colors'
|
| 'candlelight' | 'processed' | 'weather' | 'key_colors'
|
||||||
| 'game_event' | 'math_wave';
|
| 'game_event' | 'math_wave';
|
||||||
@@ -225,9 +225,6 @@ export interface ColorStripSource {
|
|||||||
// Gradient
|
// Gradient
|
||||||
stops?: ColorStop[];
|
stops?: ColorStop[];
|
||||||
|
|
||||||
// Color cycle
|
|
||||||
colors?: number[][];
|
|
||||||
|
|
||||||
// Effect
|
// Effect
|
||||||
effect_type?: string;
|
effect_type?: string;
|
||||||
palette?: string;
|
palette?: string;
|
||||||
@@ -269,6 +266,7 @@ export interface ColorStripSource {
|
|||||||
// Daylight
|
// Daylight
|
||||||
use_real_time?: boolean;
|
use_real_time?: boolean;
|
||||||
latitude?: number;
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
|
||||||
// Candlelight
|
// Candlelight
|
||||||
num_candles?: number;
|
num_candles?: number;
|
||||||
@@ -398,6 +396,7 @@ export interface DaylightValueSource extends ValueSourceBase {
|
|||||||
speed: number;
|
speed: number;
|
||||||
use_real_time: boolean;
|
use_real_time: boolean;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
min_value: number;
|
min_value: number;
|
||||||
max_value: number;
|
max_value: number;
|
||||||
}
|
}
|
||||||
@@ -414,6 +413,7 @@ export interface AnimatedColorValueSource extends ValueSourceBase {
|
|||||||
colors: number[][];
|
colors: number[][];
|
||||||
speed: number;
|
speed: number;
|
||||||
easing: string;
|
easing: string;
|
||||||
|
clock_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdaptiveTimeColorValueSource extends ValueSourceBase {
|
export interface AdaptiveTimeColorValueSource extends ValueSourceBase {
|
||||||
@@ -653,6 +653,7 @@ export interface HomeAssistantConnectionStatus {
|
|||||||
name: string;
|
name: string;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
entity_count: number;
|
entity_count: number;
|
||||||
|
host?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HomeAssistantStatusResponse {
|
export interface HomeAssistantStatusResponse {
|
||||||
@@ -834,6 +835,7 @@ export interface EngineInfo {
|
|||||||
available: boolean;
|
available: boolean;
|
||||||
has_own_displays?: boolean;
|
has_own_displays?: boolean;
|
||||||
default_config?: Record<string, any>;
|
default_config?: Record<string, any>;
|
||||||
|
config_choices?: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Display ───────────────────────────────────────────────────
|
// ── Display ───────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -62,7 +62,14 @@
|
|||||||
"displays.none": "No displays available",
|
"displays.none": "No displays available",
|
||||||
"displays.failed": "Failed to load displays",
|
"displays.failed": "Failed to load displays",
|
||||||
"displays.picker.title": "Select a Display",
|
"displays.picker.title": "Select a Display",
|
||||||
"displays.picker.title.device": "Select a Device",
|
"displays.picker.title.lead": "Select",
|
||||||
|
"displays.picker.title.accent": "Display",
|
||||||
|
"displays.picker.eyebrow.label": "Source",
|
||||||
|
"displays.picker.eyebrow.channel": "Display · Map",
|
||||||
|
"displays.picker.eyebrow.channel.device": "Device · List",
|
||||||
|
"displays.picker.foot.dismiss": "dismiss",
|
||||||
|
"displays.picker.foot.select": "select monitor",
|
||||||
|
"displays.picker.foot.select.device": "select device",
|
||||||
"displays.picker.select": "Select display...",
|
"displays.picker.select": "Select display...",
|
||||||
"displays.picker.click_to_select": "Click to select this display",
|
"displays.picker.click_to_select": "Click to select this display",
|
||||||
"displays.picker.adb_connect": "Connect ADB device",
|
"displays.picker.adb_connect": "Connect ADB device",
|
||||||
@@ -104,6 +111,12 @@
|
|||||||
"templates.config.camera_backend.dshow": "Windows DirectShow",
|
"templates.config.camera_backend.dshow": "Windows DirectShow",
|
||||||
"templates.config.camera_backend.msmf": "Windows Media Foundation",
|
"templates.config.camera_backend.msmf": "Windows Media Foundation",
|
||||||
"templates.config.camera_backend.v4l2": "Linux Video4Linux2",
|
"templates.config.camera_backend.v4l2": "Linux Video4Linux2",
|
||||||
|
"templates.config.resolution.auto": "Open at the camera's max supported mode",
|
||||||
|
"templates.config.resolution.480p": "VGA — lowest CPU and bandwidth",
|
||||||
|
"templates.config.resolution.720p": "HD",
|
||||||
|
"templates.config.resolution.1080p": "Full HD",
|
||||||
|
"templates.config.resolution.1440p": "QHD / 2K",
|
||||||
|
"templates.config.resolution.2160p": "4K UHD — highest CPU and bandwidth",
|
||||||
"templates.created": "Template created successfully",
|
"templates.created": "Template created successfully",
|
||||||
"templates.updated": "Template updated successfully",
|
"templates.updated": "Template updated successfully",
|
||||||
"templates.deleted": "Template deleted successfully",
|
"templates.deleted": "Template deleted successfully",
|
||||||
@@ -116,6 +129,8 @@
|
|||||||
"templates.test.description": "Test this template before saving to see a capture preview and performance metrics.",
|
"templates.test.description": "Test this template before saving to see a capture preview and performance metrics.",
|
||||||
"templates.test.display": "Display:",
|
"templates.test.display": "Display:",
|
||||||
"templates.test.display.select": "Select display...",
|
"templates.test.display.select": "Select display...",
|
||||||
|
"templates.test.device": "Device:",
|
||||||
|
"templates.test.device.select": "Select device...",
|
||||||
"templates.test.duration": "Capture Duration (s):",
|
"templates.test.duration": "Capture Duration (s):",
|
||||||
"templates.test.border_width": "Border Width (px):",
|
"templates.test.border_width": "Border Width (px):",
|
||||||
"templates.test.run": "Run",
|
"templates.test.run": "Run",
|
||||||
@@ -138,6 +153,7 @@
|
|||||||
"templates.test.results.resolution": "Resolution:",
|
"templates.test.results.resolution": "Resolution:",
|
||||||
"templates.test.error.no_engine": "Please select a capture engine",
|
"templates.test.error.no_engine": "Please select a capture engine",
|
||||||
"templates.test.error.no_display": "Please select a display",
|
"templates.test.error.no_display": "Please select a display",
|
||||||
|
"templates.test.error.no_device": "Please select a device",
|
||||||
"templates.test.error.failed": "Test failed",
|
"templates.test.error.failed": "Test failed",
|
||||||
"devices.title": "Devices",
|
"devices.title": "Devices",
|
||||||
"device.select_type": "Select Device Type",
|
"device.select_type": "Select Device Type",
|
||||||
@@ -1121,8 +1137,6 @@
|
|||||||
"color_strip.type.static.desc": "Single solid color fill",
|
"color_strip.type.static.desc": "Single solid color fill",
|
||||||
"color_strip.type.gradient": "Gradient",
|
"color_strip.type.gradient": "Gradient",
|
||||||
"color_strip.type.gradient.desc": "Smooth color transition across LEDs",
|
"color_strip.type.gradient.desc": "Smooth color transition across LEDs",
|
||||||
"color_strip.type.color_cycle": "Color Cycle",
|
|
||||||
"color_strip.type.color_cycle.desc": "Cycle through a list of colors",
|
|
||||||
"color_strip.static_color": "Color:",
|
"color_strip.static_color": "Color:",
|
||||||
"color_strip.static_color.hint": "The solid color that will be sent to all LEDs on the strip.",
|
"color_strip.static_color.hint": "The solid color that will be sent to all LEDs on the strip.",
|
||||||
"color_strip.gradient.preview": "Gradient:",
|
"color_strip.gradient.preview": "Gradient:",
|
||||||
@@ -1174,7 +1188,6 @@
|
|||||||
"color_strip.animation.type.none.desc": "Static colors with no animation",
|
"color_strip.animation.type.none.desc": "Static colors with no animation",
|
||||||
"color_strip.animation.type.breathing": "Breathing",
|
"color_strip.animation.type.breathing": "Breathing",
|
||||||
"color_strip.animation.type.breathing.desc": "Smooth brightness fade in and out",
|
"color_strip.animation.type.breathing.desc": "Smooth brightness fade in and out",
|
||||||
"color_strip.animation.type.color_cycle": "Color Cycle",
|
|
||||||
"color_strip.animation.type.gradient_shift": "Gradient Shift",
|
"color_strip.animation.type.gradient_shift": "Gradient Shift",
|
||||||
"color_strip.animation.type.gradient_shift.desc": "Slides the gradient along the strip",
|
"color_strip.animation.type.gradient_shift.desc": "Slides the gradient along the strip",
|
||||||
"color_strip.animation.type.wave": "Wave",
|
"color_strip.animation.type.wave": "Wave",
|
||||||
@@ -1195,12 +1208,6 @@
|
|||||||
"color_strip.animation.type.hue_rotate.desc": "Smoothly rotates all pixel hues while preserving saturation and brightness",
|
"color_strip.animation.type.hue_rotate.desc": "Smoothly rotates all pixel hues while preserving saturation and brightness",
|
||||||
"color_strip.animation.speed": "Speed:",
|
"color_strip.animation.speed": "Speed:",
|
||||||
"color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.",
|
"color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.",
|
||||||
"color_strip.color_cycle.colors": "Colors:",
|
|
||||||
"color_strip.color_cycle.colors.hint": "List of colors to cycle through smoothly. At least 2 required. Default is a full rainbow spectrum.",
|
|
||||||
"color_strip.color_cycle.add_color": "+ Add Color",
|
|
||||||
"color_strip.color_cycle.speed": "Speed:",
|
|
||||||
"color_strip.color_cycle.speed.hint": "Cycle speed multiplier. 1.0 ≈ one full cycle every 20 seconds; higher values cycle faster.",
|
|
||||||
"color_strip.color_cycle.min_colors": "Color cycle must have at least 2 colors",
|
|
||||||
"color_strip.type.effect": "Effect",
|
"color_strip.type.effect": "Effect",
|
||||||
"color_strip.type.effect.desc": "Procedural effects like fire, plasma, aurora",
|
"color_strip.type.effect.desc": "Procedural effects like fire, plasma, aurora",
|
||||||
"color_strip.type.effect.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.",
|
"color_strip.type.effect.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.",
|
||||||
@@ -1636,7 +1643,7 @@
|
|||||||
"value_source.type.adaptive_scene": "Adaptive (Scene)",
|
"value_source.type.adaptive_scene": "Adaptive (Scene)",
|
||||||
"value_source.type.adaptive_scene.desc": "Adjusts by scene content",
|
"value_source.type.adaptive_scene.desc": "Adjusts by scene content",
|
||||||
"value_source.type.daylight": "Daylight Cycle",
|
"value_source.type.daylight": "Daylight Cycle",
|
||||||
"value_source.type.daylight.desc": "Brightness follows day/night cycle",
|
"value_source.type.daylight.desc": "Value follows day/night cycle",
|
||||||
"value_source.type.static_color": "Static Color",
|
"value_source.type.static_color": "Static Color",
|
||||||
"value_source.type.static_color.desc": "Fixed RGB color",
|
"value_source.type.static_color.desc": "Fixed RGB color",
|
||||||
"value_source.type.animated_color": "Animated Color",
|
"value_source.type.animated_color": "Animated Color",
|
||||||
@@ -1706,18 +1713,30 @@
|
|||||||
"value_source.animated_color.speed": "Speed (cpm):",
|
"value_source.animated_color.speed": "Speed (cpm):",
|
||||||
"value_source.animated_color.easing": "Easing:",
|
"value_source.animated_color.easing": "Easing:",
|
||||||
"value_source.animated_color.easing.linear": "Linear",
|
"value_source.animated_color.easing.linear": "Linear",
|
||||||
"value_source.animated_color.easing.linear.desc": "Smooth blend between colors",
|
"value_source.animated_color.easing.linear.desc": "Constant rate blend between colors",
|
||||||
"value_source.animated_color.easing.step": "Step",
|
"value_source.animated_color.easing.step": "Step",
|
||||||
"value_source.animated_color.easing.step.desc": "Instant jump between colors",
|
"value_source.animated_color.easing.step.desc": "Instant jump between colors",
|
||||||
|
"value_source.animated_color.easing.ease_in": "Ease In",
|
||||||
|
"value_source.animated_color.easing.ease_in.desc": "Slow start, accelerating into the next color",
|
||||||
|
"value_source.animated_color.easing.ease_out": "Ease Out",
|
||||||
|
"value_source.animated_color.easing.ease_out.desc": "Quick start, settling into the next color",
|
||||||
|
"value_source.animated_color.easing.ease_in_out": "Ease In Out",
|
||||||
|
"value_source.animated_color.easing.ease_in_out.desc": "Smooth at both ends, faster in the middle",
|
||||||
|
"value_source.animated_color.easing.sine": "Sine",
|
||||||
|
"value_source.animated_color.easing.sine.desc": "Sinusoidal curve — natural ambient cycling",
|
||||||
"value_source.animated_color.color_count": "colors",
|
"value_source.animated_color.color_count": "colors",
|
||||||
|
"value_source.animated_color.clock": "Animation Clock:",
|
||||||
|
"value_source.animated_color.clock.hint": "Optional sync clock — when set, the clock controls timing (its speed multiplier scales the cpm above) and pause state. Leave empty to run on the value source's own speed.",
|
||||||
"value_source.adaptive_time_color.schedule": "Color Schedule:",
|
"value_source.adaptive_time_color.schedule": "Color Schedule:",
|
||||||
"value_source.daylight.speed": "Speed:",
|
"value_source.daylight.speed": "Speed:",
|
||||||
"value_source.daylight.speed.hint": "Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.",
|
"value_source.daylight.speed.hint": "Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.",
|
||||||
"value_source.daylight.use_real_time": "Use Real Time:",
|
"value_source.daylight.use_real_time": "Use Real Time:",
|
||||||
"value_source.daylight.use_real_time.hint": "When enabled, brightness follows the actual time of day. Speed is ignored.",
|
"value_source.daylight.use_real_time.hint": "When enabled, the value follows the actual time of day. Speed is ignored.",
|
||||||
"value_source.daylight.enable_real_time": "Follow wall clock",
|
"value_source.daylight.enable_real_time": "Follow wall clock",
|
||||||
"value_source.daylight.latitude": "Latitude:",
|
"value_source.daylight.latitude": "Latitude:",
|
||||||
"value_source.daylight.latitude.hint": "Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.",
|
"value_source.daylight.latitude.hint": "Your geographic latitude (-90 to 90). Steepens or flattens the sunrise/sunset edges of the cycle.",
|
||||||
|
"value_source.daylight.longitude": "Longitude:",
|
||||||
|
"value_source.daylight.longitude.hint": "Your geographic longitude (-180 to 180). Shifts solar noon for accurate sunrise/sunset alignment with your wall clock.",
|
||||||
"value_source.daylight.real_time": "Real-time",
|
"value_source.daylight.real_time": "Real-time",
|
||||||
"value_source.daylight.speed_label": "Speed",
|
"value_source.daylight.speed_label": "Speed",
|
||||||
"value_source.value": "Value:",
|
"value_source.value": "Value:",
|
||||||
@@ -1864,6 +1883,22 @@
|
|||||||
"settings.shutdown_action.opt.stop_desc": "Run the normal stop sequence (per-device auto-restore applies)",
|
"settings.shutdown_action.opt.stop_desc": "Run the normal stop sequence (per-device auto-restore applies)",
|
||||||
"settings.shutdown_action.opt.nothing": "Nothing",
|
"settings.shutdown_action.opt.nothing": "Nothing",
|
||||||
"settings.shutdown_action.opt.nothing_desc": "Leave lights showing the last frame",
|
"settings.shutdown_action.opt.nothing_desc": "Leave lights showing the last frame",
|
||||||
|
"settings.daylight_timezone.label": "Daylight timezone",
|
||||||
|
"settings.daylight_timezone.hint": "IANA timezone every \"real-time\" daylight cycle reads its wall clock in. Empty (default) uses the server's system timezone.",
|
||||||
|
"settings.daylight_timezone.saved": "Daylight timezone saved",
|
||||||
|
"settings.daylight_timezone.save_error": "Failed to save daylight timezone",
|
||||||
|
"common.daylight_tz.system": "System default",
|
||||||
|
"common.daylight_tz.system_desc": "Server clock — no override",
|
||||||
|
"common.daylight_tz.detected": "Browser-detected",
|
||||||
|
"common.daylight_tz.detected_label": "Auto-detected",
|
||||||
|
"common.daylight_tz.search": "Search timezones, cities, regions…",
|
||||||
|
"common.daylight_tz.region.utc": "Universal",
|
||||||
|
"common.daylight_tz.region.europe": "Europe",
|
||||||
|
"common.daylight_tz.region.africa": "Africa",
|
||||||
|
"common.daylight_tz.region.me": "Middle East",
|
||||||
|
"common.daylight_tz.region.asia": "Asia",
|
||||||
|
"common.daylight_tz.region.pacific": "Pacific",
|
||||||
|
"common.daylight_tz.region.americas": "Americas",
|
||||||
"settings.auto_backup.label": "Auto-Backup",
|
"settings.auto_backup.label": "Auto-Backup",
|
||||||
"settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.",
|
"settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.",
|
||||||
"settings.auto_backup.enable": "Enable auto-backup",
|
"settings.auto_backup.enable": "Enable auto-backup",
|
||||||
|
|||||||
@@ -66,7 +66,14 @@
|
|||||||
"displays.none": "Нет доступных дисплеев",
|
"displays.none": "Нет доступных дисплеев",
|
||||||
"displays.failed": "Не удалось загрузить дисплеи",
|
"displays.failed": "Не удалось загрузить дисплеи",
|
||||||
"displays.picker.title": "Выберите Дисплей",
|
"displays.picker.title": "Выберите Дисплей",
|
||||||
"displays.picker.title.device": "Выберите Устройство",
|
"displays.picker.title.lead": "Выбрать",
|
||||||
|
"displays.picker.title.accent": "Дисплей",
|
||||||
|
"displays.picker.eyebrow.label": "Источник",
|
||||||
|
"displays.picker.eyebrow.channel": "Дисплей · Карта",
|
||||||
|
"displays.picker.eyebrow.channel.device": "Устройство · Список",
|
||||||
|
"displays.picker.foot.dismiss": "закрыть",
|
||||||
|
"displays.picker.foot.select": "выбрать монитор",
|
||||||
|
"displays.picker.foot.select.device": "выбрать устройство",
|
||||||
"displays.picker.select": "Выберите дисплей...",
|
"displays.picker.select": "Выберите дисплей...",
|
||||||
"displays.picker.click_to_select": "Нажмите, чтобы выбрать этот дисплей",
|
"displays.picker.click_to_select": "Нажмите, чтобы выбрать этот дисплей",
|
||||||
"displays.picker.adb_connect": "Подключить ADB устройство",
|
"displays.picker.adb_connect": "Подключить ADB устройство",
|
||||||
@@ -108,6 +115,12 @@
|
|||||||
"templates.config.camera_backend.dshow": "Windows DirectShow",
|
"templates.config.camera_backend.dshow": "Windows DirectShow",
|
||||||
"templates.config.camera_backend.msmf": "Windows Media Foundation",
|
"templates.config.camera_backend.msmf": "Windows Media Foundation",
|
||||||
"templates.config.camera_backend.v4l2": "Linux Video4Linux2",
|
"templates.config.camera_backend.v4l2": "Linux Video4Linux2",
|
||||||
|
"templates.config.resolution.auto": "Открыть в максимальном поддерживаемом разрешении камеры",
|
||||||
|
"templates.config.resolution.480p": "VGA — минимум CPU и трафика",
|
||||||
|
"templates.config.resolution.720p": "HD",
|
||||||
|
"templates.config.resolution.1080p": "Full HD",
|
||||||
|
"templates.config.resolution.1440p": "QHD / 2K",
|
||||||
|
"templates.config.resolution.2160p": "4K UHD — максимум CPU и трафика",
|
||||||
"templates.created": "Шаблон успешно создан",
|
"templates.created": "Шаблон успешно создан",
|
||||||
"templates.updated": "Шаблон успешно обновлён",
|
"templates.updated": "Шаблон успешно обновлён",
|
||||||
"templates.deleted": "Шаблон успешно удалён",
|
"templates.deleted": "Шаблон успешно удалён",
|
||||||
@@ -120,6 +133,8 @@
|
|||||||
"templates.test.description": "Протестируйте этот шаблон перед сохранением, чтобы увидеть предпросмотр захвата и метрики производительности.",
|
"templates.test.description": "Протестируйте этот шаблон перед сохранением, чтобы увидеть предпросмотр захвата и метрики производительности.",
|
||||||
"templates.test.display": "Дисплей:",
|
"templates.test.display": "Дисплей:",
|
||||||
"templates.test.display.select": "Выберите дисплей...",
|
"templates.test.display.select": "Выберите дисплей...",
|
||||||
|
"templates.test.device": "Устройство:",
|
||||||
|
"templates.test.device.select": "Выберите устройство...",
|
||||||
"templates.test.duration": "Длительность Захвата (с):",
|
"templates.test.duration": "Длительность Захвата (с):",
|
||||||
"templates.test.border_width": "Ширина Границы (px):",
|
"templates.test.border_width": "Ширина Границы (px):",
|
||||||
"templates.test.run": "Запустить",
|
"templates.test.run": "Запустить",
|
||||||
@@ -142,6 +157,7 @@
|
|||||||
"templates.test.results.resolution": "Разрешение:",
|
"templates.test.results.resolution": "Разрешение:",
|
||||||
"templates.test.error.no_engine": "Пожалуйста, выберите движок захвата",
|
"templates.test.error.no_engine": "Пожалуйста, выберите движок захвата",
|
||||||
"templates.test.error.no_display": "Пожалуйста, выберите дисплей",
|
"templates.test.error.no_display": "Пожалуйста, выберите дисплей",
|
||||||
|
"templates.test.error.no_device": "Пожалуйста, выберите устройство",
|
||||||
"templates.test.error.failed": "Тест не удался",
|
"templates.test.error.failed": "Тест не удался",
|
||||||
"devices.title": "Устройства",
|
"devices.title": "Устройства",
|
||||||
"device.select_type": "Выберите тип устройства",
|
"device.select_type": "Выберите тип устройства",
|
||||||
@@ -1102,8 +1118,6 @@
|
|||||||
"color_strip.type.static.desc": "Заливка одним цветом",
|
"color_strip.type.static.desc": "Заливка одним цветом",
|
||||||
"color_strip.type.gradient": "Градиент",
|
"color_strip.type.gradient": "Градиент",
|
||||||
"color_strip.type.gradient.desc": "Плавный переход цветов по ленте",
|
"color_strip.type.gradient.desc": "Плавный переход цветов по ленте",
|
||||||
"color_strip.type.color_cycle": "Смена цвета",
|
|
||||||
"color_strip.type.color_cycle.desc": "Циклическая смена списка цветов",
|
|
||||||
"color_strip.static_color": "Цвет:",
|
"color_strip.static_color": "Цвет:",
|
||||||
"color_strip.static_color.hint": "Статический цвет, который будет отправлен на все светодиоды полосы.",
|
"color_strip.static_color.hint": "Статический цвет, который будет отправлен на все светодиоды полосы.",
|
||||||
"color_strip.gradient.preview": "Градиент:",
|
"color_strip.gradient.preview": "Градиент:",
|
||||||
@@ -1142,7 +1156,6 @@
|
|||||||
"color_strip.animation.type.none.desc": "Статичные цвета без анимации",
|
"color_strip.animation.type.none.desc": "Статичные цвета без анимации",
|
||||||
"color_strip.animation.type.breathing": "Дыхание",
|
"color_strip.animation.type.breathing": "Дыхание",
|
||||||
"color_strip.animation.type.breathing.desc": "Плавное угасание и нарастание яркости",
|
"color_strip.animation.type.breathing.desc": "Плавное угасание и нарастание яркости",
|
||||||
"color_strip.animation.type.color_cycle": "Смена цвета",
|
|
||||||
"color_strip.animation.type.gradient_shift": "Сдвиг градиента",
|
"color_strip.animation.type.gradient_shift": "Сдвиг градиента",
|
||||||
"color_strip.animation.type.gradient_shift.desc": "Сдвигает градиент вдоль ленты",
|
"color_strip.animation.type.gradient_shift.desc": "Сдвигает градиент вдоль ленты",
|
||||||
"color_strip.animation.type.wave": "Волна",
|
"color_strip.animation.type.wave": "Волна",
|
||||||
@@ -1159,12 +1172,6 @@
|
|||||||
"color_strip.animation.type.rainbow_fade.desc": "Циклический переход по всему спектру оттенков",
|
"color_strip.animation.type.rainbow_fade.desc": "Циклический переход по всему спектру оттенков",
|
||||||
"color_strip.animation.speed": "Скорость:",
|
"color_strip.animation.speed": "Скорость:",
|
||||||
"color_strip.animation.speed.hint": "Множитель скорости анимации. 1.0 ≈ один цикл в секунду для дыхания; большие значения ускоряют анимацию.",
|
"color_strip.animation.speed.hint": "Множитель скорости анимации. 1.0 ≈ один цикл в секунду для дыхания; большие значения ускоряют анимацию.",
|
||||||
"color_strip.color_cycle.colors": "Цвета:",
|
|
||||||
"color_strip.color_cycle.colors.hint": "Список цветов для плавной циклической смены. Минимум 2 цвета. По умолчанию — полный радужный спектр.",
|
|
||||||
"color_strip.color_cycle.add_color": "+ Добавить цвет",
|
|
||||||
"color_strip.color_cycle.speed": "Скорость:",
|
|
||||||
"color_strip.color_cycle.speed.hint": "Множитель скорости смены. 1.0 ≈ один полный цикл за 20 секунд; большие значения ускоряют смену.",
|
|
||||||
"color_strip.color_cycle.min_colors": "Смена цвета должна содержать не менее 2 цветов",
|
|
||||||
"color_strip.type.effect": "Эффект",
|
"color_strip.type.effect": "Эффект",
|
||||||
"color_strip.type.effect.desc": "Процедурные эффекты: огонь, плазма, аврора",
|
"color_strip.type.effect.desc": "Процедурные эффекты: огонь, плазма, аврора",
|
||||||
"color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.",
|
"color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.",
|
||||||
@@ -1526,14 +1533,16 @@
|
|||||||
"value_source.type.adaptive_scene": "Адаптивный (Сцена)",
|
"value_source.type.adaptive_scene": "Адаптивный (Сцена)",
|
||||||
"value_source.type.adaptive_scene.desc": "Подстройка по содержимому сцены",
|
"value_source.type.adaptive_scene.desc": "Подстройка по содержимому сцены",
|
||||||
"value_source.type.daylight": "Дневной цикл",
|
"value_source.type.daylight": "Дневной цикл",
|
||||||
"value_source.type.daylight.desc": "Яркость следует за циклом дня/ночи",
|
"value_source.type.daylight.desc": "Значение следует циклу дня/ночи",
|
||||||
"value_source.daylight.speed": "Скорость:",
|
"value_source.daylight.speed": "Скорость:",
|
||||||
"value_source.daylight.speed.hint": "Множитель скорости цикла. 1.0 = полный цикл день/ночь за ~4 минуты.",
|
"value_source.daylight.speed.hint": "Множитель скорости цикла. 1.0 = полный цикл день/ночь за ~4 минуты.",
|
||||||
"value_source.daylight.use_real_time": "Реальное время:",
|
"value_source.daylight.use_real_time": "Реальное время:",
|
||||||
"value_source.daylight.use_real_time.hint": "Яркость следует за реальным временем суток. Скорость игнорируется.",
|
"value_source.daylight.use_real_time.hint": "Значение следует за реальным временем суток. Скорость игнорируется.",
|
||||||
"value_source.daylight.enable_real_time": "Следовать за часами",
|
"value_source.daylight.enable_real_time": "Следовать за часами",
|
||||||
"value_source.daylight.latitude": "Широта:",
|
"value_source.daylight.latitude": "Широта:",
|
||||||
"value_source.daylight.latitude.hint": "Географическая широта (-90 до 90). Влияет на время восхода/заката в режиме реального времени.",
|
"value_source.daylight.latitude.hint": "Географическая широта (-90 до 90). Делает переходы рассвета и заката круче или плавнее.",
|
||||||
|
"value_source.daylight.longitude": "Долгота:",
|
||||||
|
"value_source.daylight.longitude.hint": "Географическая долгота (-180 до 180). Сдвигает солнечный полдень для точного совпадения восхода и заката с настенными часами.",
|
||||||
"value_source.daylight.real_time": "Реальное время",
|
"value_source.daylight.real_time": "Реальное время",
|
||||||
"value_source.daylight.speed_label": "Скорость",
|
"value_source.daylight.speed_label": "Скорость",
|
||||||
"value_source.value": "Значение:",
|
"value_source.value": "Значение:",
|
||||||
@@ -1680,6 +1689,22 @@
|
|||||||
"settings.shutdown_action.opt.stop_desc": "Обычная остановка (учитывается авто-восстановление устройств)",
|
"settings.shutdown_action.opt.stop_desc": "Обычная остановка (учитывается авто-восстановление устройств)",
|
||||||
"settings.shutdown_action.opt.nothing": "Ничего",
|
"settings.shutdown_action.opt.nothing": "Ничего",
|
||||||
"settings.shutdown_action.opt.nothing_desc": "Оставить свет на последнем кадре",
|
"settings.shutdown_action.opt.nothing_desc": "Оставить свет на последнем кадре",
|
||||||
|
"settings.daylight_timezone.label": "Часовой пояс дневного цикла",
|
||||||
|
"settings.daylight_timezone.hint": "IANA часовой пояс, в котором каждый дневной цикл «реального времени» читает настенные часы. Пусто (по умолчанию) — использовать системный часовой пояс сервера.",
|
||||||
|
"settings.daylight_timezone.saved": "Часовой пояс дневного цикла сохранён",
|
||||||
|
"settings.daylight_timezone.save_error": "Не удалось сохранить часовой пояс",
|
||||||
|
"common.daylight_tz.system": "Системный по умолчанию",
|
||||||
|
"common.daylight_tz.system_desc": "Часы сервера — без переопределения",
|
||||||
|
"common.daylight_tz.detected": "Определён браузером",
|
||||||
|
"common.daylight_tz.detected_label": "Авто-определение",
|
||||||
|
"common.daylight_tz.search": "Поиск по поясам, городам, регионам…",
|
||||||
|
"common.daylight_tz.region.utc": "Универсальное",
|
||||||
|
"common.daylight_tz.region.europe": "Европа",
|
||||||
|
"common.daylight_tz.region.africa": "Африка",
|
||||||
|
"common.daylight_tz.region.me": "Ближний Восток",
|
||||||
|
"common.daylight_tz.region.asia": "Азия",
|
||||||
|
"common.daylight_tz.region.pacific": "Тихий океан",
|
||||||
|
"common.daylight_tz.region.americas": "Америка",
|
||||||
"settings.auto_backup.label": "Авто-бэкап",
|
"settings.auto_backup.label": "Авто-бэкап",
|
||||||
"settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.",
|
"settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.",
|
||||||
"settings.auto_backup.enable": "Включить авто-бэкап",
|
"settings.auto_backup.enable": "Включить авто-бэкап",
|
||||||
|
|||||||
@@ -66,7 +66,14 @@
|
|||||||
"displays.none": "没有可用的显示器",
|
"displays.none": "没有可用的显示器",
|
||||||
"displays.failed": "加载显示器失败",
|
"displays.failed": "加载显示器失败",
|
||||||
"displays.picker.title": "选择显示器",
|
"displays.picker.title": "选择显示器",
|
||||||
"displays.picker.title.device": "选择设备",
|
"displays.picker.title.lead": "选择",
|
||||||
|
"displays.picker.title.accent": "显示器",
|
||||||
|
"displays.picker.eyebrow.label": "来源",
|
||||||
|
"displays.picker.eyebrow.channel": "显示器 · 布局",
|
||||||
|
"displays.picker.eyebrow.channel.device": "设备 · 列表",
|
||||||
|
"displays.picker.foot.dismiss": "关闭",
|
||||||
|
"displays.picker.foot.select": "选择显示器",
|
||||||
|
"displays.picker.foot.select.device": "选择设备",
|
||||||
"displays.picker.select": "选择显示器...",
|
"displays.picker.select": "选择显示器...",
|
||||||
"displays.picker.click_to_select": "点击选择此显示器",
|
"displays.picker.click_to_select": "点击选择此显示器",
|
||||||
"displays.picker.adb_connect": "连接 ADB 设备",
|
"displays.picker.adb_connect": "连接 ADB 设备",
|
||||||
@@ -108,6 +115,12 @@
|
|||||||
"templates.config.camera_backend.dshow": "Windows DirectShow",
|
"templates.config.camera_backend.dshow": "Windows DirectShow",
|
||||||
"templates.config.camera_backend.msmf": "Windows Media Foundation",
|
"templates.config.camera_backend.msmf": "Windows Media Foundation",
|
||||||
"templates.config.camera_backend.v4l2": "Linux Video4Linux2",
|
"templates.config.camera_backend.v4l2": "Linux Video4Linux2",
|
||||||
|
"templates.config.resolution.auto": "以摄像头支持的最高模式打开",
|
||||||
|
"templates.config.resolution.480p": "VGA — CPU 与带宽最低",
|
||||||
|
"templates.config.resolution.720p": "HD",
|
||||||
|
"templates.config.resolution.1080p": "Full HD",
|
||||||
|
"templates.config.resolution.1440p": "QHD / 2K",
|
||||||
|
"templates.config.resolution.2160p": "4K UHD — CPU 与带宽最高",
|
||||||
"templates.created": "模板创建成功",
|
"templates.created": "模板创建成功",
|
||||||
"templates.updated": "模板更新成功",
|
"templates.updated": "模板更新成功",
|
||||||
"templates.deleted": "模板删除成功",
|
"templates.deleted": "模板删除成功",
|
||||||
@@ -120,6 +133,8 @@
|
|||||||
"templates.test.description": "保存前测试此模板,查看采集预览和性能指标。",
|
"templates.test.description": "保存前测试此模板,查看采集预览和性能指标。",
|
||||||
"templates.test.display": "显示器:",
|
"templates.test.display": "显示器:",
|
||||||
"templates.test.display.select": "选择显示器...",
|
"templates.test.display.select": "选择显示器...",
|
||||||
|
"templates.test.device": "设备:",
|
||||||
|
"templates.test.device.select": "选择设备...",
|
||||||
"templates.test.duration": "采集时长(秒):",
|
"templates.test.duration": "采集时长(秒):",
|
||||||
"templates.test.border_width": "边框宽度(像素):",
|
"templates.test.border_width": "边框宽度(像素):",
|
||||||
"templates.test.run": "运行",
|
"templates.test.run": "运行",
|
||||||
@@ -142,6 +157,7 @@
|
|||||||
"templates.test.results.resolution": "分辨率:",
|
"templates.test.results.resolution": "分辨率:",
|
||||||
"templates.test.error.no_engine": "请选择采集引擎",
|
"templates.test.error.no_engine": "请选择采集引擎",
|
||||||
"templates.test.error.no_display": "请选择显示器",
|
"templates.test.error.no_display": "请选择显示器",
|
||||||
|
"templates.test.error.no_device": "请选择设备",
|
||||||
"templates.test.error.failed": "测试失败",
|
"templates.test.error.failed": "测试失败",
|
||||||
"devices.title": "设备",
|
"devices.title": "设备",
|
||||||
"device.select_type": "选择设备类型",
|
"device.select_type": "选择设备类型",
|
||||||
@@ -1102,8 +1118,6 @@
|
|||||||
"color_strip.type.static.desc": "单色填充",
|
"color_strip.type.static.desc": "单色填充",
|
||||||
"color_strip.type.gradient": "渐变",
|
"color_strip.type.gradient": "渐变",
|
||||||
"color_strip.type.gradient.desc": "LED上的平滑颜色过渡",
|
"color_strip.type.gradient.desc": "LED上的平滑颜色过渡",
|
||||||
"color_strip.type.color_cycle": "颜色循环",
|
|
||||||
"color_strip.type.color_cycle.desc": "循环切换颜色列表",
|
|
||||||
"color_strip.static_color": "颜色:",
|
"color_strip.static_color": "颜色:",
|
||||||
"color_strip.static_color.hint": "将发送到灯带上所有 LED 的纯色。",
|
"color_strip.static_color.hint": "将发送到灯带上所有 LED 的纯色。",
|
||||||
"color_strip.gradient.preview": "渐变:",
|
"color_strip.gradient.preview": "渐变:",
|
||||||
@@ -1142,7 +1156,6 @@
|
|||||||
"color_strip.animation.type.none.desc": "静态颜色,无动画",
|
"color_strip.animation.type.none.desc": "静态颜色,无动画",
|
||||||
"color_strip.animation.type.breathing": "呼吸",
|
"color_strip.animation.type.breathing": "呼吸",
|
||||||
"color_strip.animation.type.breathing.desc": "平滑的亮度渐入渐出",
|
"color_strip.animation.type.breathing.desc": "平滑的亮度渐入渐出",
|
||||||
"color_strip.animation.type.color_cycle": "颜色循环",
|
|
||||||
"color_strip.animation.type.gradient_shift": "渐变移动",
|
"color_strip.animation.type.gradient_shift": "渐变移动",
|
||||||
"color_strip.animation.type.gradient_shift.desc": "渐变沿灯带滑动",
|
"color_strip.animation.type.gradient_shift.desc": "渐变沿灯带滑动",
|
||||||
"color_strip.animation.type.wave": "波浪",
|
"color_strip.animation.type.wave": "波浪",
|
||||||
@@ -1159,12 +1172,6 @@
|
|||||||
"color_strip.animation.type.rainbow_fade.desc": "循环整个色相光谱",
|
"color_strip.animation.type.rainbow_fade.desc": "循环整个色相光谱",
|
||||||
"color_strip.animation.speed": "速度:",
|
"color_strip.animation.speed": "速度:",
|
||||||
"color_strip.animation.speed.hint": "动画速度倍数。1.0 ≈ 呼吸效果每秒一个循环;更高值循环更快。",
|
"color_strip.animation.speed.hint": "动画速度倍数。1.0 ≈ 呼吸效果每秒一个循环;更高值循环更快。",
|
||||||
"color_strip.color_cycle.colors": "颜色:",
|
|
||||||
"color_strip.color_cycle.colors.hint": "平滑循环的颜色列表。至少需要 2 种。默认为全彩虹光谱。",
|
|
||||||
"color_strip.color_cycle.add_color": "+ 添加颜色",
|
|
||||||
"color_strip.color_cycle.speed": "速度:",
|
|
||||||
"color_strip.color_cycle.speed.hint": "循环速度倍数。1.0 ≈ 每 20 秒一个完整循环;更高值循环更快。",
|
|
||||||
"color_strip.color_cycle.min_colors": "颜色循环至少需要 2 种颜色",
|
|
||||||
"color_strip.type.effect": "效果",
|
"color_strip.type.effect": "效果",
|
||||||
"color_strip.type.effect.desc": "程序化效果:火焰、等离子、极光",
|
"color_strip.type.effect.desc": "程序化效果:火焰、等离子、极光",
|
||||||
"color_strip.type.effect.hint": "实时生成的程序化 LED 效果(火焰、流星、等离子、噪声、极光)。",
|
"color_strip.type.effect.hint": "实时生成的程序化 LED 效果(火焰、流星、等离子、噪声、极光)。",
|
||||||
@@ -1526,14 +1533,16 @@
|
|||||||
"value_source.type.adaptive_scene": "自适应(场景)",
|
"value_source.type.adaptive_scene": "自适应(场景)",
|
||||||
"value_source.type.adaptive_scene.desc": "按场景内容调节",
|
"value_source.type.adaptive_scene.desc": "按场景内容调节",
|
||||||
"value_source.type.daylight": "日光周期",
|
"value_source.type.daylight": "日光周期",
|
||||||
"value_source.type.daylight.desc": "亮度跟随日夜周期",
|
"value_source.type.daylight.desc": "数值跟随昼夜周期",
|
||||||
"value_source.daylight.speed": "速度:",
|
"value_source.daylight.speed": "速度:",
|
||||||
"value_source.daylight.speed.hint": "周期速度倍率。1.0 = 完整日夜周期约4分钟。",
|
"value_source.daylight.speed.hint": "周期速度倍率。1.0 = 完整日夜周期约4分钟。",
|
||||||
"value_source.daylight.use_real_time": "使用实时:",
|
"value_source.daylight.use_real_time": "使用实时:",
|
||||||
"value_source.daylight.use_real_time.hint": "启用后,亮度跟随实际时间。速度设置将被忽略。",
|
"value_source.daylight.use_real_time.hint": "启用后,数值跟随实际时间。速度设置将被忽略。",
|
||||||
"value_source.daylight.enable_real_time": "跟随系统时钟",
|
"value_source.daylight.enable_real_time": "跟随系统时钟",
|
||||||
"value_source.daylight.latitude": "纬度:",
|
"value_source.daylight.latitude": "纬度:",
|
||||||
"value_source.daylight.latitude.hint": "地理纬度(-90到90)。影响实时模式下的日出/日落时间。",
|
"value_source.daylight.latitude.hint": "地理纬度(-90到90)。使日出/日落过渡更陡峭或更平缓。",
|
||||||
|
"value_source.daylight.longitude": "经度:",
|
||||||
|
"value_source.daylight.longitude.hint": "地理经度(-180到180)。偏移正午时刻,使日出/日落与挂钟时间对齐。",
|
||||||
"value_source.daylight.real_time": "实时",
|
"value_source.daylight.real_time": "实时",
|
||||||
"value_source.daylight.speed_label": "速度",
|
"value_source.daylight.speed_label": "速度",
|
||||||
"value_source.value": "值:",
|
"value_source.value": "值:",
|
||||||
@@ -1680,6 +1689,22 @@
|
|||||||
"settings.shutdown_action.opt.stop_desc": "执行正常停止流程(按设备应用自动恢复)",
|
"settings.shutdown_action.opt.stop_desc": "执行正常停止流程(按设备应用自动恢复)",
|
||||||
"settings.shutdown_action.opt.nothing": "无",
|
"settings.shutdown_action.opt.nothing": "无",
|
||||||
"settings.shutdown_action.opt.nothing_desc": "让灯保持最后一帧",
|
"settings.shutdown_action.opt.nothing_desc": "让灯保持最后一帧",
|
||||||
|
"settings.daylight_timezone.label": "日光周期时区",
|
||||||
|
"settings.daylight_timezone.hint": "所有“实时”日光周期读取挂钟时间所用的 IANA 时区。留空(默认)则使用服务器的系统时区。",
|
||||||
|
"settings.daylight_timezone.saved": "日光周期时区已保存",
|
||||||
|
"settings.daylight_timezone.save_error": "保存日光周期时区失败",
|
||||||
|
"common.daylight_tz.system": "系统默认",
|
||||||
|
"common.daylight_tz.system_desc": "服务器时钟 — 不覆盖",
|
||||||
|
"common.daylight_tz.detected": "浏览器检测",
|
||||||
|
"common.daylight_tz.detected_label": "自动检测",
|
||||||
|
"common.daylight_tz.search": "搜索时区、城市、地区…",
|
||||||
|
"common.daylight_tz.region.utc": "世界标准",
|
||||||
|
"common.daylight_tz.region.europe": "欧洲",
|
||||||
|
"common.daylight_tz.region.africa": "非洲",
|
||||||
|
"common.daylight_tz.region.me": "中东",
|
||||||
|
"common.daylight_tz.region.asia": "亚洲",
|
||||||
|
"common.daylight_tz.region.pacific": "太平洋",
|
||||||
|
"common.daylight_tz.region.americas": "美洲",
|
||||||
"settings.auto_backup.label": "自动备份",
|
"settings.auto_backup.label": "自动备份",
|
||||||
"settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。",
|
"settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。",
|
||||||
"settings.auto_backup.enable": "启用自动备份",
|
"settings.auto_backup.enable": "启用自动备份",
|
||||||
|
|||||||
@@ -97,11 +97,13 @@ class CaptureAudioSource(AudioSource):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "CaptureAudioSource":
|
def from_dict(cls, data: dict) -> "CaptureAudioSource":
|
||||||
common = _parse_common_fields(data)
|
common = _parse_common_fields(data)
|
||||||
|
raw_device_index = data.get("device_index")
|
||||||
|
raw_is_loopback = data.get("is_loopback")
|
||||||
return cls(
|
return cls(
|
||||||
**common,
|
**common,
|
||||||
source_type="capture",
|
source_type="capture",
|
||||||
device_index=int(data.get("device_index", -1)),
|
device_index=int(raw_device_index) if raw_device_index is not None else -1,
|
||||||
is_loopback=bool(data.get("is_loopback", True)),
|
is_loopback=bool(raw_is_loopback) if raw_is_loopback is not None else True,
|
||||||
audio_template_id=data.get("audio_template_id"),
|
audio_template_id=data.get("audio_template_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ Current types:
|
|||||||
AdvancedPictureColorStripSource — line-based calibration across multiple PictureSources
|
AdvancedPictureColorStripSource — line-based calibration across multiple PictureSources
|
||||||
StaticColorStripSource — constant solid color fills all LEDs
|
StaticColorStripSource — constant solid color fills all LEDs
|
||||||
GradientColorStripSource — linear gradient across all LEDs from user-defined color stops
|
GradientColorStripSource — linear gradient across all LEDs from user-defined color stops
|
||||||
ColorCycleColorStripSource — smoothly cycles through a user-defined list of colors
|
|
||||||
AudioColorStripSource — audio-reactive visualization (spectrum, beat pulse, VU meter)
|
AudioColorStripSource — audio-reactive visualization (spectrum, beat pulse, VU meter)
|
||||||
ApiInputColorStripSource — receives raw LED colors from external clients via REST/WebSocket
|
ApiInputColorStripSource — receives raw LED colors from external clients via REST/WebSocket
|
||||||
NotificationColorStripSource — fires one-shot visual alerts (flash, pulse, sweep) via API
|
NotificationColorStripSource — fires one-shot visual alerts (flash, pulse, sweep) via API
|
||||||
@@ -69,7 +68,7 @@ class ColorStripSource:
|
|||||||
def sharable(self) -> bool:
|
def sharable(self) -> bool:
|
||||||
"""Whether multiple consumers can share a single stream instance.
|
"""Whether multiple consumers can share a single stream instance.
|
||||||
|
|
||||||
Count-dependent sources (static, gradient, cycle, effect) return False
|
Count-dependent sources (static, gradient, effect) return False
|
||||||
because each consumer may configure a different LED count.
|
because each consumer may configure a different LED count.
|
||||||
"""
|
"""
|
||||||
return False
|
return False
|
||||||
@@ -517,82 +516,6 @@ class GradientColorStripSource(ColorStripSource):
|
|||||||
self.gradient_id = kwargs["gradient_id"]
|
self.gradient_id = kwargs["gradient_id"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ColorCycleColorStripSource(ColorStripSource):
|
|
||||||
"""Color strip source that smoothly cycles through a user-defined list of colors.
|
|
||||||
|
|
||||||
All LEDs receive the same solid color at any point in time, smoothly
|
|
||||||
interpolating between the configured color stops in a continuous loop.
|
|
||||||
LED count auto-sizes from the connected device.
|
|
||||||
"""
|
|
||||||
|
|
||||||
colors: list = field(
|
|
||||||
default_factory=lambda: [
|
|
||||||
[255, 0, 0],
|
|
||||||
[255, 255, 0],
|
|
||||||
[0, 255, 0],
|
|
||||||
[0, 255, 255],
|
|
||||||
[0, 0, 255],
|
|
||||||
[255, 0, 255],
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
d = super().to_dict()
|
|
||||||
d["colors"] = [list(c) for c in self.colors]
|
|
||||||
return d
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict) -> "ColorCycleColorStripSource":
|
|
||||||
common = _parse_css_common(data)
|
|
||||||
raw_colors = data.get("colors")
|
|
||||||
return cls(
|
|
||||||
**common,
|
|
||||||
source_type="color_cycle",
|
|
||||||
colors=raw_colors if isinstance(raw_colors, list) else [],
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create_from_kwargs(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
id: str,
|
|
||||||
name: str,
|
|
||||||
source_type: str,
|
|
||||||
created_at: datetime,
|
|
||||||
updated_at: datetime,
|
|
||||||
description=None,
|
|
||||||
clock_id=None,
|
|
||||||
tags=None,
|
|
||||||
colors=None,
|
|
||||||
**_kwargs,
|
|
||||||
):
|
|
||||||
default_colors = [
|
|
||||||
[255, 0, 0],
|
|
||||||
[255, 255, 0],
|
|
||||||
[0, 255, 0],
|
|
||||||
[0, 255, 255],
|
|
||||||
[0, 0, 255],
|
|
||||||
[255, 0, 255],
|
|
||||||
]
|
|
||||||
return cls(
|
|
||||||
id=id,
|
|
||||||
name=name,
|
|
||||||
source_type="color_cycle",
|
|
||||||
created_at=created_at,
|
|
||||||
updated_at=updated_at,
|
|
||||||
description=description,
|
|
||||||
clock_id=clock_id,
|
|
||||||
tags=tags or [],
|
|
||||||
colors=colors if isinstance(colors, list) and len(colors) >= 2 else default_colors,
|
|
||||||
)
|
|
||||||
|
|
||||||
def apply_update(self, **kwargs) -> None:
|
|
||||||
colors = kwargs.get("colors")
|
|
||||||
if colors is not None and isinstance(colors, list) and len(colors) >= 2:
|
|
||||||
self.colors = colors
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EffectColorStripSource(ColorStripSource):
|
class EffectColorStripSource(ColorStripSource):
|
||||||
"""Color strip source that runs a procedural LED effect.
|
"""Color strip source that runs a procedural LED effect.
|
||||||
@@ -1893,7 +1816,6 @@ _SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = {
|
|||||||
"picture_advanced": AdvancedPictureColorStripSource,
|
"picture_advanced": AdvancedPictureColorStripSource,
|
||||||
"static": StaticColorStripSource,
|
"static": StaticColorStripSource,
|
||||||
"gradient": GradientColorStripSource,
|
"gradient": GradientColorStripSource,
|
||||||
"color_cycle": ColorCycleColorStripSource,
|
|
||||||
"effect": EffectColorStripSource,
|
"effect": EffectColorStripSource,
|
||||||
"audio": AudioColorStripSource,
|
"audio": AudioColorStripSource,
|
||||||
"composite": CompositeColorStripSource,
|
"composite": CompositeColorStripSource,
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ from ledgrab.storage.base_sqlite_store import BaseSqliteStore
|
|||||||
from ledgrab.storage.database import Database
|
from ledgrab.storage.database import Database
|
||||||
from ledgrab.storage.utils import resolve_ref
|
from ledgrab.storage.utils import resolve_ref
|
||||||
from ledgrab.storage.color_strip_source import (
|
from ledgrab.storage.color_strip_source import (
|
||||||
|
AdvancedPictureColorStripSource,
|
||||||
ColorStripSource,
|
ColorStripSource,
|
||||||
CompositeColorStripSource,
|
CompositeColorStripSource,
|
||||||
MappedColorStripSource,
|
MappedColorStripSource,
|
||||||
|
PictureColorStripSource,
|
||||||
ProcessedColorStripSource,
|
ProcessedColorStripSource,
|
||||||
)
|
)
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
@@ -252,3 +254,24 @@ class ColorStripStore(BaseSqliteStore[ColorStripSource]):
|
|||||||
if source.input_source_id == source_id:
|
if source.input_source_id == source_id:
|
||||||
names.append(source.name)
|
names.append(source.name)
|
||||||
return names
|
return names
|
||||||
|
|
||||||
|
def get_referencing_picture_source(self, picture_source_id: str) -> List[ColorStripSource]:
|
||||||
|
"""Return color strip sources that reference a given picture source.
|
||||||
|
|
||||||
|
Includes simple `PictureColorStripSource` (via `picture_source_id`) and
|
||||||
|
`AdvancedPictureColorStripSource` (via per-line calibration entries).
|
||||||
|
"""
|
||||||
|
result: List[ColorStripSource] = []
|
||||||
|
for source in self._items.values():
|
||||||
|
if (
|
||||||
|
isinstance(source, PictureColorStripSource)
|
||||||
|
and source.picture_source_id == picture_source_id
|
||||||
|
):
|
||||||
|
result.append(source)
|
||||||
|
elif isinstance(source, AdvancedPictureColorStripSource):
|
||||||
|
lines = getattr(source.calibration, "lines", None) or []
|
||||||
|
if any(
|
||||||
|
getattr(line, "picture_source_id", "") == picture_source_id for line in lines
|
||||||
|
):
|
||||||
|
result.append(source)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -305,9 +305,29 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
|
|||||||
|
|
||||||
# ── Query helpers ─────────────────────────────────────────────────
|
# ── Query helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_targets_referencing(self, stream_id: str, target_store) -> List[str]:
|
def get_targets_referencing(self, stream_id: str, target_store, css_store=None) -> List[str]:
|
||||||
"""Return names of targets that reference this stream."""
|
"""Return names of output targets that transitively reference this stream.
|
||||||
return target_store.get_targets_referencing_source(stream_id)
|
|
||||||
|
Output targets reference picture sources only via color strip sources, so
|
||||||
|
this walks: picture source → CSS → target. Returns deduped target names
|
||||||
|
in stable order. ``css_store`` is required for transitive resolution; if
|
||||||
|
omitted, returns an empty list (legacy callers without a CSS store).
|
||||||
|
"""
|
||||||
|
if css_store is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
css_list = css_store.get_referencing_picture_source(stream_id)
|
||||||
|
if not css_list:
|
||||||
|
return []
|
||||||
|
|
||||||
|
seen: Set[str] = set()
|
||||||
|
ordered: List[str] = []
|
||||||
|
for css in css_list:
|
||||||
|
for name in target_store.get_targets_referencing_css(css.id):
|
||||||
|
if name not in seen:
|
||||||
|
seen.add(name)
|
||||||
|
ordered.append(name)
|
||||||
|
return ordered
|
||||||
|
|
||||||
def resolve_stream_chain(self, stream_id: str) -> dict:
|
def resolve_stream_chain(self, stream_id: str) -> dict:
|
||||||
"""Resolve a stream chain to get the terminal stream and collected postprocessing templates.
|
"""Resolve a stream chain to get the terminal stream and collected postprocessing templates.
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class ValueSource:
|
|||||||
"scene_behavior": None,
|
"scene_behavior": None,
|
||||||
"use_real_time": None,
|
"use_real_time": None,
|
||||||
"latitude": None,
|
"latitude": None,
|
||||||
|
"longitude": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -254,6 +255,7 @@ class DaylightValueSource(ValueSource):
|
|||||||
speed: float = 1.0 # simulation speed (ignored when use_real_time)
|
speed: float = 1.0 # simulation speed (ignored when use_real_time)
|
||||||
use_real_time: bool = False # use wall clock instead of simulation
|
use_real_time: bool = False # use wall clock instead of simulation
|
||||||
latitude: float = 50.0 # affects sunrise/sunset in real-time mode
|
latitude: float = 50.0 # affects sunrise/sunset in real-time mode
|
||||||
|
longitude: float = 0.0 # affects solar noon offset
|
||||||
min_value: float = 0.0 # output range min
|
min_value: float = 0.0 # output range min
|
||||||
max_value: float = 1.0 # output range max
|
max_value: float = 1.0 # output range max
|
||||||
|
|
||||||
@@ -262,6 +264,7 @@ class DaylightValueSource(ValueSource):
|
|||||||
d["speed"] = self.speed
|
d["speed"] = self.speed
|
||||||
d["use_real_time"] = self.use_real_time
|
d["use_real_time"] = self.use_real_time
|
||||||
d["latitude"] = self.latitude
|
d["latitude"] = self.latitude
|
||||||
|
d["longitude"] = self.longitude
|
||||||
d["min_value"] = self.min_value
|
d["min_value"] = self.min_value
|
||||||
d["max_value"] = self.max_value
|
d["max_value"] = self.max_value
|
||||||
return d
|
return d
|
||||||
@@ -275,6 +278,7 @@ class DaylightValueSource(ValueSource):
|
|||||||
speed=float(data.get("speed") or 1.0),
|
speed=float(data.get("speed") or 1.0),
|
||||||
use_real_time=bool(data.get("use_real_time", False)),
|
use_real_time=bool(data.get("use_real_time", False)),
|
||||||
latitude=float(data.get("latitude") or 50.0),
|
latitude=float(data.get("latitude") or 50.0),
|
||||||
|
longitude=float(data.get("longitude") or 0.0),
|
||||||
min_value=float(data.get("min_value") or 0.0),
|
min_value=float(data.get("min_value") or 0.0),
|
||||||
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0,
|
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0,
|
||||||
)
|
)
|
||||||
@@ -312,12 +316,14 @@ class AnimatedColorValueSource(ValueSource):
|
|||||||
colors: list = field(default_factory=lambda: [[255, 0, 0], [0, 255, 0], [0, 0, 255]])
|
colors: list = field(default_factory=lambda: [[255, 0, 0], [0, 255, 0], [0, 0, 255]])
|
||||||
speed: float = 10.0 # cycles per minute
|
speed: float = 10.0 # cycles per minute
|
||||||
easing: str = "linear" # linear | step
|
easing: str = "linear" # linear | step
|
||||||
|
clock_id: Optional[str] = None # optional SyncClock reference for shared timing
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
d["colors"] = [list(c) for c in self.colors]
|
d["colors"] = [list(c) for c in self.colors]
|
||||||
d["speed"] = self.speed
|
d["speed"] = self.speed
|
||||||
d["easing"] = self.easing
|
d["easing"] = self.easing
|
||||||
|
d["clock_id"] = self.clock_id
|
||||||
d["return_type"] = "color"
|
d["return_type"] = "color"
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@@ -336,6 +342,7 @@ class AnimatedColorValueSource(ValueSource):
|
|||||||
colors=colors,
|
colors=colors,
|
||||||
speed=float(data.get("speed") or 10.0),
|
speed=float(data.get("speed") or 10.0),
|
||||||
easing=data.get("easing") or "linear",
|
easing=data.get("easing") or "linear",
|
||||||
|
clock_id=data.get("clock_id") or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
|||||||
auto_gain: Optional[bool] = None,
|
auto_gain: Optional[bool] = None,
|
||||||
use_real_time: Optional[bool] = None,
|
use_real_time: Optional[bool] = None,
|
||||||
latitude: Optional[float] = None,
|
latitude: Optional[float] = None,
|
||||||
|
longitude: Optional[float] = None,
|
||||||
color: Optional[list] = None,
|
color: Optional[list] = None,
|
||||||
colors: Optional[list] = None,
|
colors: Optional[list] = None,
|
||||||
easing: Optional[str] = None,
|
easing: Optional[str] = None,
|
||||||
@@ -83,6 +84,7 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
|||||||
disk_path: Optional[str] = None,
|
disk_path: Optional[str] = None,
|
||||||
sensor_label: Optional[str] = None,
|
sensor_label: Optional[str] = None,
|
||||||
poll_interval: Optional[float] = None,
|
poll_interval: Optional[float] = None,
|
||||||
|
clock_id: Optional[str] = None,
|
||||||
) -> ValueSource:
|
) -> ValueSource:
|
||||||
_VALID = (
|
_VALID = (
|
||||||
"static",
|
"static",
|
||||||
@@ -195,6 +197,7 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
|||||||
speed=speed if speed is not None else 1.0,
|
speed=speed if speed is not None else 1.0,
|
||||||
use_real_time=bool(use_real_time) if use_real_time is not None else False,
|
use_real_time=bool(use_real_time) if use_real_time is not None else False,
|
||||||
latitude=latitude if latitude is not None else 50.0,
|
latitude=latitude if latitude is not None else 50.0,
|
||||||
|
longitude=longitude if longitude is not None else 0.0,
|
||||||
min_value=min_value if min_value is not None else 0.0,
|
min_value=min_value if min_value is not None else 0.0,
|
||||||
max_value=max_value if max_value is not None else 1.0,
|
max_value=max_value if max_value is not None else 1.0,
|
||||||
)
|
)
|
||||||
@@ -225,6 +228,7 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
|||||||
),
|
),
|
||||||
speed=speed if speed is not None else 10.0,
|
speed=speed if speed is not None else 10.0,
|
||||||
easing=easing or "linear",
|
easing=easing or "linear",
|
||||||
|
clock_id=clock_id or None,
|
||||||
)
|
)
|
||||||
elif source_type == "adaptive_time_color":
|
elif source_type == "adaptive_time_color":
|
||||||
schedule_data = schedule or []
|
schedule_data = schedule or []
|
||||||
@@ -338,6 +342,7 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
|||||||
auto_gain: Optional[bool] = None,
|
auto_gain: Optional[bool] = None,
|
||||||
use_real_time: Optional[bool] = None,
|
use_real_time: Optional[bool] = None,
|
||||||
latitude: Optional[float] = None,
|
latitude: Optional[float] = None,
|
||||||
|
longitude: Optional[float] = None,
|
||||||
color: Optional[list] = None,
|
color: Optional[list] = None,
|
||||||
colors: Optional[list] = None,
|
colors: Optional[list] = None,
|
||||||
easing: Optional[str] = None,
|
easing: Optional[str] = None,
|
||||||
@@ -357,6 +362,7 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
|||||||
disk_path: Optional[str] = None,
|
disk_path: Optional[str] = None,
|
||||||
sensor_label: Optional[str] = None,
|
sensor_label: Optional[str] = None,
|
||||||
poll_interval: Optional[float] = None,
|
poll_interval: Optional[float] = None,
|
||||||
|
clock_id: Optional[str] = None,
|
||||||
) -> ValueSource:
|
) -> ValueSource:
|
||||||
source = self.get(source_id)
|
source = self.get(source_id)
|
||||||
|
|
||||||
@@ -420,6 +426,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
|||||||
source.use_real_time = use_real_time
|
source.use_real_time = use_real_time
|
||||||
if latitude is not None:
|
if latitude is not None:
|
||||||
source.latitude = latitude
|
source.latitude = latitude
|
||||||
|
if longitude is not None:
|
||||||
|
source.longitude = longitude
|
||||||
if min_value is not None:
|
if min_value is not None:
|
||||||
source.min_value = min_value
|
source.min_value = min_value
|
||||||
if max_value is not None:
|
if max_value is not None:
|
||||||
@@ -434,6 +442,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
|||||||
source.speed = speed
|
source.speed = speed
|
||||||
if easing is not None:
|
if easing is not None:
|
||||||
source.easing = easing
|
source.easing = easing
|
||||||
|
if clock_id is not None:
|
||||||
|
source.clock_id = resolve_ref(clock_id, source.clock_id)
|
||||||
elif isinstance(source, AdaptiveTimeColorValueSource):
|
elif isinstance(source, AdaptiveTimeColorValueSource):
|
||||||
if schedule is not None:
|
if schedule is not None:
|
||||||
if len(schedule) < 2:
|
if len(schedule) < 2:
|
||||||
|
|||||||
@@ -523,6 +523,10 @@
|
|||||||
document.getElementById('targets-panel-content').innerHTML = loginMsg;
|
document.getElementById('targets-panel-content').innerHTML = loginMsg;
|
||||||
document.getElementById('streams-list').innerHTML = loginMsg;
|
document.getElementById('streams-list').innerHTML = loginMsg;
|
||||||
document.getElementById('graph-editor-content').innerHTML = loginMsg;
|
document.getElementById('graph-editor-content').innerHTML = loginMsg;
|
||||||
|
|
||||||
|
// Re-open the login modal in forced mode so the user can sign back in
|
||||||
|
// without reloading. Mirrors the initial-load path in app.ts.
|
||||||
|
showApiKeyModal(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Demo banner dismiss
|
// Demo banner dismiss
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
<option value="picture_advanced" data-i18n="color_strip.type.picture_advanced">Multi-Monitor</option>
|
<option value="picture_advanced" data-i18n="color_strip.type.picture_advanced">Multi-Monitor</option>
|
||||||
<option value="static" data-i18n="color_strip.type.static">Static Color</option>
|
<option value="static" data-i18n="color_strip.type.static">Static Color</option>
|
||||||
<option value="gradient" data-i18n="color_strip.type.gradient">Gradient</option>
|
<option value="gradient" data-i18n="color_strip.type.gradient">Gradient</option>
|
||||||
<option value="color_cycle" data-i18n="color_strip.type.color_cycle">Color Cycle</option>
|
|
||||||
<option value="effect" data-i18n="color_strip.type.effect">Procedural Effect</option>
|
<option value="effect" data-i18n="color_strip.type.effect">Procedural Effect</option>
|
||||||
<option value="composite" data-i18n="color_strip.type.composite">Composite</option>
|
<option value="composite" data-i18n="color_strip.type.composite">Composite</option>
|
||||||
<option value="mapped" data-i18n="color_strip.type.mapped">Mapped</option>
|
<option value="mapped" data-i18n="color_strip.type.mapped">Mapped</option>
|
||||||
@@ -111,18 +110,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Color-cycle-specific fields -->
|
|
||||||
<div id="css-editor-color-cycle-section" style="display:none">
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="label-row">
|
|
||||||
<label data-i18n="color_strip.color_cycle.colors">Colors:</label>
|
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
|
||||||
</div>
|
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.color_cycle.colors.hint">Colors to cycle through smoothly. At least 2 required.</small>
|
|
||||||
<div id="color-cycle-colors-list"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Gradient-specific fields -->
|
<!-- Gradient-specific fields -->
|
||||||
<div id="css-editor-gradient-section" style="display:none">
|
<div id="css-editor-gradient-section" style="display:none">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -542,15 +529,6 @@
|
|||||||
|
|
||||||
<!-- Daylight Cycle section -->
|
<!-- Daylight Cycle section -->
|
||||||
<div id="css-editor-daylight-section" style="display:none">
|
<div id="css-editor-daylight-section" style="display:none">
|
||||||
<div id="css-editor-daylight-speed-group" class="form-group">
|
|
||||||
<div class="label-row">
|
|
||||||
<label for="css-editor-daylight-speed"><span data-i18n="color_strip.daylight.speed">Speed:</span> <span id="css-editor-daylight-speed-val">1.0</span></label>
|
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
|
||||||
</div>
|
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.speed.hint">Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.</small>
|
|
||||||
<input type="range" id="css-editor-daylight-speed" min="0.1" max="10" step="0.1" value="1.0"
|
|
||||||
oninput="document.getElementById('css-editor-daylight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-daylight-real-time" data-i18n="color_strip.daylight.use_real_time">Use Real Time:</label>
|
<label for="css-editor-daylight-real-time" data-i18n="color_strip.daylight.use_real_time">Use Real Time:</label>
|
||||||
@@ -562,12 +540,21 @@
|
|||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="css-editor-daylight-speed-group" class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="css-editor-daylight-speed"><span data-i18n="color_strip.daylight.speed">Speed:</span> <span id="css-editor-daylight-speed-val">1.0</span></label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.speed.hint">Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.</small>
|
||||||
|
<input type="range" id="css-editor-daylight-speed" min="0.1" max="10" step="0.1" value="1.0"
|
||||||
|
oninput="document.getElementById('css-editor-daylight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-daylight-latitude"><span data-i18n="color_strip.daylight.latitude">Latitude:</span> <span id="css-editor-daylight-latitude-val">50</span>°</label>
|
<label for="css-editor-daylight-latitude"><span data-i18n="color_strip.daylight.latitude">Latitude:</span> <span id="css-editor-daylight-latitude-val">50</span>°</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.latitude.hint">Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.latitude.hint">Your geographic latitude (-90 to 90). Steepens or flattens the sunrise/sunset edges of the cycle.</small>
|
||||||
<input type="range" id="css-editor-daylight-latitude" min="-90" max="90" step="1" value="50"
|
<input type="range" id="css-editor-daylight-latitude" min="-90" max="90" step="1" value="50"
|
||||||
oninput="document.getElementById('css-editor-daylight-latitude-val').textContent = parseInt(this.value)">
|
oninput="document.getElementById('css-editor-daylight-latitude-val').textContent = parseInt(this.value)">
|
||||||
</div>
|
</div>
|
||||||
@@ -576,7 +563,7 @@
|
|||||||
<label for="css-editor-daylight-longitude"><span data-i18n="color_strip.daylight.longitude">Longitude:</span> <span id="css-editor-daylight-longitude-val">0</span>°</label>
|
<label for="css-editor-daylight-longitude"><span data-i18n="color_strip.daylight.longitude">Longitude:</span> <span id="css-editor-daylight-longitude-val">0</span>°</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.longitude.hint">Your geographic longitude (-180 to 180). Adjusts solar noon offset for accurate sunrise/sunset timing.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.longitude.hint">Your geographic longitude (-180 to 180). Shifts solar noon for accurate sunrise/sunset alignment with your wall clock.</small>
|
||||||
<input type="range" id="css-editor-daylight-longitude" min="-180" max="180" step="1" value="0"
|
<input type="range" id="css-editor-daylight-longitude" min="-180" max="180" step="1" value="0"
|
||||||
oninput="document.getElementById('css-editor-daylight-longitude-val').textContent = parseInt(this.value)">
|
oninput="document.getElementById('css-editor-daylight-longitude-val').textContent = parseInt(this.value)">
|
||||||
</div>
|
</div>
|
||||||
@@ -832,7 +819,7 @@
|
|||||||
<small id="css-editor-animation-type-desc" class="field-desc"></small>
|
<small id="css-editor-animation-type-desc" class="field-desc"></small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sync Clock (shown for animated types: static, gradient, color_cycle, effect) -->
|
<!-- Sync Clock (shown for animated types: static, gradient, effect) -->
|
||||||
<div id="css-editor-clock-group" class="form-group" style="display:none">
|
<div id="css-editor-clock-group" class="form-group" style="display:none">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-clock" data-i18n="color_strip.clock">Sync Clock:</label>
|
<label for="css-editor-clock" data-i18n="color_strip.clock">Sync Clock:</label>
|
||||||
|
|||||||
@@ -136,6 +136,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="settings-daylight-timezone" data-i18n="settings.daylight_timezone.label">Daylight timezone</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="settings.daylight_timezone.hint">IANA timezone every "real-time" daylight cycle reads its wall clock in. Empty (default) uses the server's system timezone.</small>
|
||||||
|
<div class="tz-picker-wrap">
|
||||||
|
<select id="settings-daylight-timezone"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="save-bar" id="settings-external-url-save-bar" hidden>
|
<div class="save-bar" id="settings-external-url-save-bar" hidden>
|
||||||
<div class="save-bar-msg">
|
<div class="save-bar-msg">
|
||||||
<span data-i18n="settings.save_bar.unsaved">Unsaved changes in</span>
|
<span data-i18n="settings.save_bar.unsaved">Unsaved changes in</span>
|
||||||
@@ -618,10 +629,6 @@
|
|||||||
<span class="rn-eyebrow__sep" aria-hidden="true"></span>
|
<span class="rn-eyebrow__sep" aria-hidden="true"></span>
|
||||||
<span class="rn-eyebrow__channel" id="release-notes-channel">CHANGELOG</span>
|
<span class="rn-eyebrow__channel" id="release-notes-channel">CHANGELOG</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="rn-title">
|
|
||||||
<span class="rn-title__main" id="release-notes-name">Release Notes</span>
|
|
||||||
<em class="rn-title__accent" id="release-notes-version" hidden></em>
|
|
||||||
</h2>
|
|
||||||
<div class="rn-meta" id="release-notes-meta" hidden>
|
<div class="rn-meta" id="release-notes-meta" hidden>
|
||||||
<span class="rn-chip" id="release-notes-tag-chip" hidden>
|
<span class="rn-chip" id="release-notes-tag-chip" hidden>
|
||||||
<span class="rn-chip__dot" aria-hidden="true"></span>
|
<span class="rn-chip__dot" aria-hidden="true"></span>
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ds-section-body">
|
<div class="ds-section-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="templates.test.display">Display:</label>
|
<label id="test-template-display-label" data-i18n="templates.test.display">Display:</label>
|
||||||
<input type="hidden" id="test-template-display" value="">
|
<input type="hidden" id="test-template-display" value="">
|
||||||
<button type="button" class="btn btn-display-picker" id="test-display-picker-btn" onclick="openDisplayPicker(onTestDisplaySelected, document.getElementById('test-template-display').value, window._engineHasOwnDisplays?.(window.currentTestingTemplate?.engine_type) ? window.currentTestingTemplate.engine_type : null)">
|
<button type="button" class="btn btn-display-picker" id="test-display-picker-btn" onclick="openTestDisplayPicker()">
|
||||||
<span id="test-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
|
<span id="test-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -257,6 +257,18 @@
|
|||||||
|
|
||||||
<!-- Daylight fields -->
|
<!-- Daylight fields -->
|
||||||
<div id="value-source-daylight-section" style="display:none">
|
<div id="value-source-daylight-section" style="display:none">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="value-source-daylight-real-time" data-i18n="value_source.daylight.use_real_time">Use Real Time:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="value_source.daylight.use_real_time.hint">When enabled, the value follows the actual time of day. Speed is ignored.</small>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="value-source-daylight-real-time" onchange="onDaylightVSRealTimeChange()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="value-source-daylight-speed-group" class="form-group">
|
<div id="value-source-daylight-speed-group" class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="value-source-daylight-speed"><span data-i18n="value_source.daylight.speed">Speed:</span> <span id="value-source-daylight-speed-display">1.0</span></label>
|
<label for="value-source-daylight-speed"><span data-i18n="value_source.daylight.speed">Speed:</span> <span id="value-source-daylight-speed-display">1.0</span></label>
|
||||||
@@ -269,24 +281,22 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="value-source-daylight-real-time" data-i18n="value_source.daylight.use_real_time">Use Real Time:</label>
|
<label for="value-source-daylight-latitude"><span data-i18n="value_source.daylight.latitude">Latitude:</span> <span id="value-source-daylight-latitude-display">50</span>°</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="value_source.daylight.use_real_time.hint">When enabled, brightness follows the actual time of day. Speed is ignored.</small>
|
<small class="input-hint" style="display:none" data-i18n="value_source.daylight.latitude.hint">Your geographic latitude (-90 to 90). Steepens or flattens the sunrise/sunset edges of the cycle.</small>
|
||||||
<label class="toggle-label">
|
<input type="range" id="value-source-daylight-latitude" min="-90" max="90" step="1" value="50"
|
||||||
<input type="checkbox" id="value-source-daylight-real-time" onchange="onDaylightVSRealTimeChange()">
|
oninput="document.getElementById('value-source-daylight-latitude-display').textContent = parseInt(this.value)">
|
||||||
<span data-i18n="value_source.daylight.enable_real_time">Follow wall clock</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="value-source-daylight-latitude"><span data-i18n="value_source.daylight.latitude">Latitude:</span> <span id="value-source-daylight-latitude-display">50</span>°</label>
|
<label for="value-source-daylight-longitude"><span data-i18n="value_source.daylight.longitude">Longitude:</span> <span id="value-source-daylight-longitude-display">0</span>°</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="value_source.daylight.latitude.hint">Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.</small>
|
<small class="input-hint" style="display:none" data-i18n="value_source.daylight.longitude.hint">Your geographic longitude (-180 to 180). Shifts solar noon for accurate sunrise/sunset alignment with your wall clock.</small>
|
||||||
<input type="range" id="value-source-daylight-latitude" min="-90" max="90" step="1" value="50"
|
<input type="range" id="value-source-daylight-longitude" min="-180" max="180" step="1" value="0"
|
||||||
oninput="document.getElementById('value-source-daylight-latitude-display').textContent = parseInt(this.value)">
|
oninput="document.getElementById('value-source-daylight-longitude-display').textContent = parseInt(this.value)">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -306,8 +316,7 @@
|
|||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label data-i18n="value_source.animated_color.colors">Colors:</label>
|
<label data-i18n="value_source.animated_color.colors">Colors:</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="value-source-animated-color-list"></div>
|
<div id="value-source-animated-color-list" class="animated-color-row" role="list"></div>
|
||||||
<button type="button" class="btn btn-sm" onclick="addAnimatedColor()">+ Add Color</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
@@ -326,8 +335,20 @@
|
|||||||
<select id="value-source-animated-color-easing">
|
<select id="value-source-animated-color-easing">
|
||||||
<option value="linear">Linear</option>
|
<option value="linear">Linear</option>
|
||||||
<option value="step">Step</option>
|
<option value="step">Step</option>
|
||||||
|
<option value="ease_in">Ease In</option>
|
||||||
|
<option value="ease_out">Ease Out</option>
|
||||||
|
<option value="ease_in_out">Ease In Out</option>
|
||||||
|
<option value="sine">Sine</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="value-source-animated-color-clock" data-i18n="value_source.animated_color.clock">Animation Clock:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="value_source.animated_color.clock.hint">Optional sync clock — when set, the clock controls timing (its speed multiplier scales the cpm above) and pause state. Leave empty to run on the value source's own speed.</small>
|
||||||
|
<select id="value-source-animated-color-clock"></select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Adaptive Time Color fields -->
|
<!-- Adaptive Time Color fields -->
|
||||||
|
|||||||
@@ -1,10 +1,46 @@
|
|||||||
<!-- Display Picker Lightbox -->
|
<!--
|
||||||
<div id="display-picker-lightbox" class="lightbox" onclick="closeDisplayPicker(event)">
|
Display Picker Lightbox — Lumenworks studio-console shell.
|
||||||
<button class="lightbox-close" onclick="closeDisplayPicker()" title="Close">✕</button>
|
|
||||||
|
Channel: cyan (sources / screen). Re-uses the .lightbox shell tokens
|
||||||
|
in modal.css; .lightbox-bracket-br paints the second silkscreen
|
||||||
|
corner (top-left bracket comes from .lightbox-content::after).
|
||||||
|
|
||||||
|
The header rack (eyebrow + display title) and the optional foot key
|
||||||
|
hint are scoped to .display-picker-content so the image lightbox
|
||||||
|
keeps its minimal, image-first layout.
|
||||||
|
|
||||||
|
Title is split into a static lead word ("Select") and a channel-tinted
|
||||||
|
accent ("Display" / "Device"); displays.ts swaps the accent + eyebrow
|
||||||
|
channel label depending on whether we are picking a monitor or an
|
||||||
|
engine-owned device (camera, scrcpy).
|
||||||
|
-->
|
||||||
|
<div id="display-picker-lightbox" class="lightbox" data-ch="cyan" onclick="closeDisplayPicker(event)">
|
||||||
<div class="lightbox-content display-picker-content">
|
<div class="lightbox-content display-picker-content">
|
||||||
<h3 class="display-picker-title" data-i18n="displays.picker.title">Select a Display</h3>
|
<span class="lightbox-bracket-br" aria-hidden="true"></span>
|
||||||
|
<button class="lightbox-close" type="button" onclick="closeDisplayPicker()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||||
|
|
||||||
|
<header class="display-picker-head">
|
||||||
|
<div class="display-picker-lede">
|
||||||
|
<div class="display-picker-eyebrow" aria-hidden="true">
|
||||||
|
<span class="display-picker-eyebrow__dot"></span>
|
||||||
|
<span data-role="label" data-i18n="displays.picker.eyebrow.label">Source</span>
|
||||||
|
<span class="display-picker-eyebrow__sep"></span>
|
||||||
|
<span class="display-picker-eyebrow__channel" data-i18n="displays.picker.eyebrow.channel">Display · Map</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="display-picker-title" id="display-picker-title">
|
||||||
|
<span data-role="lead" data-i18n="displays.picker.title.lead">Select</span>
|
||||||
|
<em class="display-picker-title__accent" data-i18n="displays.picker.title.accent">Display</em>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div id="display-picker-canvas" class="display-picker-canvas">
|
<div id="display-picker-canvas" class="display-picker-canvas">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer class="display-picker-foot" aria-hidden="true">
|
||||||
|
<span><kbd>esc</kbd><span data-i18n="displays.picker.foot.dismiss">dismiss</span></span>
|
||||||
|
<span><kbd>click</kbd><span data-role="select" data-i18n="displays.picker.foot.select">select monitor</span></span>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<!-- Image Lightbox -->
|
<!--
|
||||||
<div id="image-lightbox" class="lightbox" onclick="closeLightbox(event)">
|
Image Lightbox — Lumenworks studio-console shell, minimal variant.
|
||||||
<button class="lightbox-close" onclick="closeLightbox()" title="Close">✕</button>
|
|
||||||
|
Channel: cyan (preview / screen-capture). The shell is shared with
|
||||||
|
the display picker (see modal.css). Image is the hero so we skip the
|
||||||
|
big head and only keep the close button + readout strip.
|
||||||
|
-->
|
||||||
|
<div id="image-lightbox" class="lightbox" data-ch="cyan" onclick="closeLightbox(event)">
|
||||||
<div class="lightbox-content">
|
<div class="lightbox-content">
|
||||||
|
<span class="lightbox-bracket-br" aria-hidden="true"></span>
|
||||||
|
<button class="lightbox-close" type="button" onclick="closeLightbox()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||||
<img id="lightbox-image" src="" alt="Full size preview">
|
<img id="lightbox-image" src="" alt="Full size preview">
|
||||||
<div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div>
|
<div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -135,22 +135,6 @@ class TestColorStripSourceLifecycle:
|
|||||||
resp = client.post("/api/v1/color-strip-sources", json=payload)
|
resp = client.post("/api/v1/color-strip-sources", json=payload)
|
||||||
assert resp.status_code == 400 # duplicate name
|
assert resp.status_code == 400 # duplicate name
|
||||||
|
|
||||||
def test_color_cycle_source(self, client):
|
|
||||||
"""Color cycle sources store and return their color list."""
|
|
||||||
resp = client.post(
|
|
||||||
"/api/v1/color-strip-sources",
|
|
||||||
json={
|
|
||||||
"name": "Rainbow Cycle",
|
|
||||||
"source_type": "color_cycle",
|
|
||||||
"colors": [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
|
|
||||||
"led_count": 30,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 201
|
|
||||||
data = resp.json()
|
|
||||||
assert data["source_type"] == "color_cycle"
|
|
||||||
assert data["colors"] == [[255, 0, 0], [0, 255, 0], [0, 0, 255]]
|
|
||||||
|
|
||||||
def test_effect_source(self, client):
|
def test_effect_source(self, client):
|
||||||
"""Effect sources store their effect parameters."""
|
"""Effect sources store their effect parameters."""
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
"""Tests for the camera capture engine — focus on resolution selection logic.
|
||||||
|
|
||||||
|
The probe-for-max behavior in ``_enumerate_cameras`` and the resolution
|
||||||
|
priority logic in ``CameraCaptureStream.initialize`` are exercised here
|
||||||
|
without requiring a real webcam, by stubbing ``cv2.VideoCapture``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ledgrab.core.capture_engines import camera_engine as ce
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _parse_resolution — pure function, no stubs needed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value,expected",
|
||||||
|
[
|
||||||
|
("auto", None),
|
||||||
|
("AUTO", None),
|
||||||
|
("", None),
|
||||||
|
(None, None),
|
||||||
|
(0, None),
|
||||||
|
("1920x1080", (1920, 1080)),
|
||||||
|
("1920X1080", (1920, 1080)),
|
||||||
|
("2560×1440", (2560, 1440)), # unicode multiplication sign
|
||||||
|
(" 1280x720 ", (1280, 720)),
|
||||||
|
("garbage", None),
|
||||||
|
("1920x", None),
|
||||||
|
("0x0", None),
|
||||||
|
("-1x100", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_resolution(value: Any, expected: Tuple[int, int] | None) -> None:
|
||||||
|
assert ce._parse_resolution(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stubs that emulate cv2.VideoCapture without needing a real camera
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
CAP_PROP_FRAME_WIDTH = 3
|
||||||
|
CAP_PROP_FRAME_HEIGHT = 4
|
||||||
|
CAP_PROP_FPS = 5
|
||||||
|
|
||||||
|
|
||||||
|
class _StubCap:
|
||||||
|
"""Minimal cv2.VideoCapture stand-in.
|
||||||
|
|
||||||
|
- ``default_dims``: what get() returns immediately after open.
|
||||||
|
- ``max_dims``: what set(9999) clamps to (the camera's hardware ceiling).
|
||||||
|
- ``set()`` honors any width/height up to ``max_dims`` and clamps higher.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
default_dims: Tuple[int, int],
|
||||||
|
max_dims: Tuple[int, int],
|
||||||
|
opened: bool = True,
|
||||||
|
):
|
||||||
|
self._default = default_dims
|
||||||
|
self._max = max_dims
|
||||||
|
self._w, self._h = default_dims
|
||||||
|
self._opened = opened
|
||||||
|
self.read_calls = 0
|
||||||
|
self.set_calls: List[Tuple[int, float]] = []
|
||||||
|
|
||||||
|
def isOpened(self) -> bool:
|
||||||
|
return self._opened
|
||||||
|
|
||||||
|
def get(self, prop: int) -> float:
|
||||||
|
if prop == CAP_PROP_FRAME_WIDTH:
|
||||||
|
return float(self._w)
|
||||||
|
if prop == CAP_PROP_FRAME_HEIGHT:
|
||||||
|
return float(self._h)
|
||||||
|
if prop == CAP_PROP_FPS:
|
||||||
|
return 30.0
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def set(self, prop: int, value: float) -> bool:
|
||||||
|
self.set_calls.append((prop, value))
|
||||||
|
if prop == CAP_PROP_FRAME_WIDTH:
|
||||||
|
self._w = int(min(value, self._max[0]))
|
||||||
|
elif prop == CAP_PROP_FRAME_HEIGHT:
|
||||||
|
self._h = int(min(value, self._max[1]))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
self.read_calls += 1
|
||||||
|
# Return a 3-channel frame of the current size as a list of zeros —
|
||||||
|
# the engine only inspects shape[:2].
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
return True, np.zeros((self._h, self._w, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
self._opened = False
|
||||||
|
|
||||||
|
|
||||||
|
def _install_cv2_stub(monkeypatch: pytest.MonkeyPatch, factory) -> List[_StubCap]:
|
||||||
|
"""Install a fake `cv2` module so the engine code can `import cv2`.
|
||||||
|
|
||||||
|
`factory(index, backend) -> _StubCap` produces a stub for each open call.
|
||||||
|
Returns the list that all created stubs are appended to (for assertions).
|
||||||
|
"""
|
||||||
|
created: List[_StubCap] = []
|
||||||
|
|
||||||
|
def _video_capture(index, backend=None):
|
||||||
|
cap = factory(index, backend)
|
||||||
|
created.append(cap)
|
||||||
|
return cap
|
||||||
|
|
||||||
|
fake_cv2 = types.SimpleNamespace(
|
||||||
|
VideoCapture=_video_capture,
|
||||||
|
CAP_PROP_FRAME_WIDTH=CAP_PROP_FRAME_WIDTH,
|
||||||
|
CAP_PROP_FRAME_HEIGHT=CAP_PROP_FRAME_HEIGHT,
|
||||||
|
CAP_PROP_FPS=CAP_PROP_FPS,
|
||||||
|
COLOR_BGR2RGB=4,
|
||||||
|
cvtColor=lambda frame, _flag: frame, # passthrough
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(sys.modules, "cv2", fake_cv2)
|
||||||
|
# Also bypass the SetupAPI friendly-name probe (Windows-only ctypes call)
|
||||||
|
monkeypatch.setattr(ce, "_get_camera_friendly_names", lambda: {0: "Stub Cam"})
|
||||||
|
# Reset the enumeration cache so each test sees a fresh probe
|
||||||
|
ce._camera_cache = None
|
||||||
|
ce._camera_cache_time = 0
|
||||||
|
ce._active_cv2_indices.clear()
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _enumerate_cameras — probe-for-max
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_enumerate_reports_camera_max_not_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""A camera whose default mode is 640x480 but max is 2560x1440 should be
|
||||||
|
reported at its max — that's what shows up in the display selector."""
|
||||||
|
|
||||||
|
def factory(index, _backend):
|
||||||
|
if index == 0:
|
||||||
|
return _StubCap(default_dims=(640, 480), max_dims=(2560, 1440))
|
||||||
|
return _StubCap(default_dims=(0, 0), max_dims=(0, 0), opened=False)
|
||||||
|
|
||||||
|
_install_cv2_stub(monkeypatch, factory)
|
||||||
|
|
||||||
|
cams = ce._enumerate_cameras("auto")
|
||||||
|
|
||||||
|
assert len(cams) == 1
|
||||||
|
assert cams[0]["width"] == 2560
|
||||||
|
assert cams[0]["height"] == 1440
|
||||||
|
assert cams[0]["name"] == "Stub Cam"
|
||||||
|
|
||||||
|
|
||||||
|
def test_enumerate_falls_back_when_probe_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""If a driver rejects the probe set() with an exception, fall back to the
|
||||||
|
default mode rather than reporting 0x0."""
|
||||||
|
|
||||||
|
class _RejectingCap(_StubCap):
|
||||||
|
def set(self, prop: int, value: float) -> bool:
|
||||||
|
raise RuntimeError("driver hates large values")
|
||||||
|
|
||||||
|
def factory(index, _backend):
|
||||||
|
if index == 0:
|
||||||
|
return _RejectingCap(default_dims=(800, 600), max_dims=(800, 600))
|
||||||
|
return _StubCap(default_dims=(0, 0), max_dims=(0, 0), opened=False)
|
||||||
|
|
||||||
|
_install_cv2_stub(monkeypatch, factory)
|
||||||
|
|
||||||
|
cams = ce._enumerate_cameras("auto")
|
||||||
|
|
||||||
|
assert len(cams) == 1
|
||||||
|
assert cams[0]["width"] == 800
|
||||||
|
assert cams[0]["height"] == 600
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CameraCaptureStream.initialize — resolution selection priority
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _open_stream_with_config(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
cam_max: Tuple[int, int] = (2560, 1440),
|
||||||
|
) -> Tuple[ce.CameraCaptureStream, List[_StubCap]]:
|
||||||
|
def factory(index, _backend):
|
||||||
|
if index == 0:
|
||||||
|
return _StubCap(default_dims=(640, 480), max_dims=cam_max)
|
||||||
|
return _StubCap(default_dims=(0, 0), max_dims=(0, 0), opened=False)
|
||||||
|
|
||||||
|
created = _install_cv2_stub(monkeypatch, factory)
|
||||||
|
stream = ce.CameraCaptureStream(display_index=0, config=config)
|
||||||
|
stream.initialize()
|
||||||
|
return stream, created
|
||||||
|
|
||||||
|
|
||||||
|
def _set_calls_for_size(cap: _StubCap) -> List[Tuple[int, float]]:
|
||||||
|
return [c for c in cap.set_calls if c[0] in (CAP_PROP_FRAME_WIDTH, CAP_PROP_FRAME_HEIGHT)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_default_auto_opens_at_max(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""No resolution config = open at camera max via _PROBE_MAX_DIM."""
|
||||||
|
stream, created = _open_stream_with_config(monkeypatch, config={})
|
||||||
|
# Two caps: one for enumeration probe, one for the stream's own open.
|
||||||
|
assert len(created) >= 2
|
||||||
|
stream_cap = created[-1]
|
||||||
|
size_sets = _set_calls_for_size(stream_cap)
|
||||||
|
# Stream should request the max-probe sentinel for both width and height.
|
||||||
|
assert (CAP_PROP_FRAME_WIDTH, float(ce._PROBE_MAX_DIM)) in size_sets
|
||||||
|
assert (CAP_PROP_FRAME_HEIGHT, float(ce._PROBE_MAX_DIM)) in size_sets
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_resolution_auto_opens_at_max(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
stream, created = _open_stream_with_config(monkeypatch, config={"resolution": "auto"})
|
||||||
|
stream_cap = created[-1]
|
||||||
|
size_sets = _set_calls_for_size(stream_cap)
|
||||||
|
assert (CAP_PROP_FRAME_WIDTH, float(ce._PROBE_MAX_DIM)) in size_sets
|
||||||
|
assert (CAP_PROP_FRAME_HEIGHT, float(ce._PROBE_MAX_DIM)) in size_sets
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_explicit_resolution_string(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
stream, created = _open_stream_with_config(monkeypatch, config={"resolution": "1280x720"})
|
||||||
|
stream_cap = created[-1]
|
||||||
|
size_sets = _set_calls_for_size(stream_cap)
|
||||||
|
assert (CAP_PROP_FRAME_WIDTH, 1280.0) in size_sets
|
||||||
|
assert (CAP_PROP_FRAME_HEIGHT, 720.0) in size_sets
|
||||||
|
# Should not request the max sentinel when an explicit size is given.
|
||||||
|
assert (CAP_PROP_FRAME_WIDTH, float(ce._PROBE_MAX_DIM)) not in size_sets
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_legacy_numeric_keys_take_precedence(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Stored configs from before the rename still work and override the
|
||||||
|
new `resolution` field."""
|
||||||
|
stream, created = _open_stream_with_config(
|
||||||
|
monkeypatch,
|
||||||
|
config={
|
||||||
|
"resolution": "1920x1080",
|
||||||
|
"resolution_width": 1280,
|
||||||
|
"resolution_height": 720,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
stream_cap = created[-1]
|
||||||
|
size_sets = _set_calls_for_size(stream_cap)
|
||||||
|
assert (CAP_PROP_FRAME_WIDTH, 1280.0) in size_sets
|
||||||
|
assert (CAP_PROP_FRAME_HEIGHT, 720.0) in size_sets
|
||||||
|
# `resolution` should be ignored when legacy numeric keys are set.
|
||||||
|
assert (CAP_PROP_FRAME_WIDTH, 1920.0) not in size_sets
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_legacy_zeroes_fall_through_to_resolution(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Legacy keys with value 0 are the historical 'no override' state — they
|
||||||
|
should fall through to the new `resolution` field."""
|
||||||
|
stream, created = _open_stream_with_config(
|
||||||
|
monkeypatch,
|
||||||
|
config={
|
||||||
|
"resolution": "1280x720",
|
||||||
|
"resolution_width": 0,
|
||||||
|
"resolution_height": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
stream_cap = created[-1]
|
||||||
|
size_sets = _set_calls_for_size(stream_cap)
|
||||||
|
assert (CAP_PROP_FRAME_WIDTH, 1280.0) in size_sets
|
||||||
|
assert (CAP_PROP_FRAME_HEIGHT, 720.0) in size_sets
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Engine class surface — config defaults and choices
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_config_exposes_resolution() -> None:
|
||||||
|
cfg = ce.CameraEngine.get_default_config()
|
||||||
|
assert cfg["resolution"] == "auto"
|
||||||
|
# Legacy numeric keys are no longer surfaced — UI shouldn't render them.
|
||||||
|
assert "resolution_width" not in cfg
|
||||||
|
assert "resolution_height" not in cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_choices_include_resolution_presets() -> None:
|
||||||
|
choices = ce.CameraEngine.get_config_choices()
|
||||||
|
assert "resolution" in choices
|
||||||
|
# Exact set: auto + the 5 standard sizes the UI lists.
|
||||||
|
assert choices["resolution"] == [
|
||||||
|
"auto",
|
||||||
|
"640x480",
|
||||||
|
"1280x720",
|
||||||
|
"1920x1080",
|
||||||
|
"2560x1440",
|
||||||
|
"3840x2160",
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user