Add profile conditions, scene presets, MQTT integration, and Scenes tab
Feature 1 — Profile Conditions: time-of-day, system idle (Win32 GetLastInputInfo), and display state (GUID_CONSOLE_DISPLAY_STATE) condition types for automatic profile activation. Feature 2 — Scene Presets: snapshot/restore system that captures target running states, device brightness, and profile enables. Server-side capture with 5-step activation order. Dedicated Scenes tab with CardSection-based card grid, command palette integration, and dashboard quick-activate section. Feature 3 — MQTT Integration: MQTTService singleton with aiomqtt, MQTTLEDClient device provider for pixel output, MQTT profile condition type with topic/payload matching, and frontend support for MQTT device type and condition editor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,15 @@ storage:
|
|||||||
picture_targets_file: "data/picture_targets.json"
|
picture_targets_file: "data/picture_targets.json"
|
||||||
pattern_templates_file: "data/pattern_templates.json"
|
pattern_templates_file: "data/pattern_templates.json"
|
||||||
|
|
||||||
|
mqtt:
|
||||||
|
enabled: false
|
||||||
|
broker_host: "localhost"
|
||||||
|
broker_port: 1883
|
||||||
|
username: ""
|
||||||
|
password: ""
|
||||||
|
client_id: "ledgrab"
|
||||||
|
base_topic: "ledgrab"
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
format: "json" # json or text
|
format: "json" # json or text
|
||||||
file: "logs/wled_controller.log"
|
file: "logs/wled_controller.log"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ dependencies = [
|
|||||||
"nvidia-ml-py>=12.0.0",
|
"nvidia-ml-py>=12.0.0",
|
||||||
"PyAudioWPatch>=0.2.12; sys_platform == 'win32'",
|
"PyAudioWPatch>=0.2.12; sys_platform == 'win32'",
|
||||||
"sounddevice>=0.5",
|
"sounddevice>=0.5",
|
||||||
|
"aiomqtt>=2.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .routes.audio_sources import router as audio_sources_router
|
|||||||
from .routes.audio_templates import router as audio_templates_router
|
from .routes.audio_templates import router as audio_templates_router
|
||||||
from .routes.value_sources import router as value_sources_router
|
from .routes.value_sources import router as value_sources_router
|
||||||
from .routes.profiles import router as profiles_router
|
from .routes.profiles import router as profiles_router
|
||||||
|
from .routes.scene_presets import router as scene_presets_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(system_router)
|
router.include_router(system_router)
|
||||||
@@ -30,5 +31,6 @@ router.include_router(audio_templates_router)
|
|||||||
router.include_router(value_sources_router)
|
router.include_router(value_sources_router)
|
||||||
router.include_router(picture_targets_router)
|
router.include_router(picture_targets_router)
|
||||||
router.include_router(profiles_router)
|
router.include_router(profiles_router)
|
||||||
|
router.include_router(scene_presets_router)
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from wled_controller.storage.audio_source_store import AudioSourceStore
|
|||||||
from wled_controller.storage.audio_template_store import AudioTemplateStore
|
from wled_controller.storage.audio_template_store import AudioTemplateStore
|
||||||
from wled_controller.storage.value_source_store import ValueSourceStore
|
from wled_controller.storage.value_source_store import ValueSourceStore
|
||||||
from wled_controller.storage.profile_store import ProfileStore
|
from wled_controller.storage.profile_store import ProfileStore
|
||||||
|
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||||
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
||||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ _audio_template_store: AudioTemplateStore | None = None
|
|||||||
_value_source_store: ValueSourceStore | None = None
|
_value_source_store: ValueSourceStore | None = None
|
||||||
_processor_manager: ProcessorManager | None = None
|
_processor_manager: ProcessorManager | None = None
|
||||||
_profile_store: ProfileStore | None = None
|
_profile_store: ProfileStore | None = None
|
||||||
|
_scene_preset_store: ScenePresetStore | None = None
|
||||||
_profile_engine: ProfileEngine | None = None
|
_profile_engine: ProfileEngine | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -116,6 +118,13 @@ def get_profile_store() -> ProfileStore:
|
|||||||
return _profile_store
|
return _profile_store
|
||||||
|
|
||||||
|
|
||||||
|
def get_scene_preset_store() -> ScenePresetStore:
|
||||||
|
"""Get scene preset store dependency."""
|
||||||
|
if _scene_preset_store is None:
|
||||||
|
raise RuntimeError("Scene preset store not initialized")
|
||||||
|
return _scene_preset_store
|
||||||
|
|
||||||
|
|
||||||
def get_profile_engine() -> ProfileEngine:
|
def get_profile_engine() -> ProfileEngine:
|
||||||
"""Get profile engine dependency."""
|
"""Get profile engine dependency."""
|
||||||
if _profile_engine is None:
|
if _profile_engine is None:
|
||||||
@@ -143,6 +152,7 @@ def init_dependencies(
|
|||||||
audio_template_store: AudioTemplateStore | None = None,
|
audio_template_store: AudioTemplateStore | None = None,
|
||||||
value_source_store: ValueSourceStore | None = None,
|
value_source_store: ValueSourceStore | None = None,
|
||||||
profile_store: ProfileStore | None = None,
|
profile_store: ProfileStore | None = None,
|
||||||
|
scene_preset_store: ScenePresetStore | None = None,
|
||||||
profile_engine: ProfileEngine | None = None,
|
profile_engine: ProfileEngine | None = None,
|
||||||
auto_backup_engine: AutoBackupEngine | None = None,
|
auto_backup_engine: AutoBackupEngine | None = None,
|
||||||
):
|
):
|
||||||
@@ -150,7 +160,7 @@ def init_dependencies(
|
|||||||
global _device_store, _template_store, _processor_manager
|
global _device_store, _template_store, _processor_manager
|
||||||
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
|
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
|
||||||
global _color_strip_store, _audio_source_store, _audio_template_store
|
global _color_strip_store, _audio_source_store, _audio_template_store
|
||||||
global _value_source_store, _profile_store, _profile_engine, _auto_backup_engine
|
global _value_source_store, _profile_store, _scene_preset_store, _profile_engine, _auto_backup_engine
|
||||||
_device_store = device_store
|
_device_store = device_store
|
||||||
_template_store = template_store
|
_template_store = template_store
|
||||||
_processor_manager = processor_manager
|
_processor_manager = processor_manager
|
||||||
@@ -163,5 +173,6 @@ def init_dependencies(
|
|||||||
_audio_template_store = audio_template_store
|
_audio_template_store = audio_template_store
|
||||||
_value_source_store = value_source_store
|
_value_source_store = value_source_store
|
||||||
_profile_store = profile_store
|
_profile_store = profile_store
|
||||||
|
_scene_preset_store = scene_preset_store
|
||||||
_profile_engine = profile_engine
|
_profile_engine = profile_engine
|
||||||
_auto_backup_engine = auto_backup_engine
|
_auto_backup_engine = auto_backup_engine
|
||||||
|
|||||||
@@ -17,7 +17,15 @@ from wled_controller.api.schemas.profiles import (
|
|||||||
)
|
)
|
||||||
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
||||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||||
from wled_controller.storage.profile import ApplicationCondition, Condition
|
from wled_controller.storage.profile import (
|
||||||
|
AlwaysCondition,
|
||||||
|
ApplicationCondition,
|
||||||
|
Condition,
|
||||||
|
DisplayStateCondition,
|
||||||
|
MQTTCondition,
|
||||||
|
SystemIdleCondition,
|
||||||
|
TimeOfDayCondition,
|
||||||
|
)
|
||||||
from wled_controller.storage.profile_store import ProfileStore
|
from wled_controller.storage.profile_store import ProfileStore
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
@@ -28,11 +36,33 @@ router = APIRouter()
|
|||||||
# ===== Helpers =====
|
# ===== Helpers =====
|
||||||
|
|
||||||
def _condition_from_schema(s: ConditionSchema) -> Condition:
|
def _condition_from_schema(s: ConditionSchema) -> Condition:
|
||||||
|
if s.condition_type == "always":
|
||||||
|
return AlwaysCondition()
|
||||||
if s.condition_type == "application":
|
if s.condition_type == "application":
|
||||||
return ApplicationCondition(
|
return ApplicationCondition(
|
||||||
apps=s.apps or [],
|
apps=s.apps or [],
|
||||||
match_type=s.match_type or "running",
|
match_type=s.match_type or "running",
|
||||||
)
|
)
|
||||||
|
if s.condition_type == "time_of_day":
|
||||||
|
return TimeOfDayCondition(
|
||||||
|
start_time=s.start_time or "00:00",
|
||||||
|
end_time=s.end_time or "23:59",
|
||||||
|
)
|
||||||
|
if s.condition_type == "system_idle":
|
||||||
|
return SystemIdleCondition(
|
||||||
|
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
|
||||||
|
when_idle=s.when_idle if s.when_idle is not None else True,
|
||||||
|
)
|
||||||
|
if s.condition_type == "display_state":
|
||||||
|
return DisplayStateCondition(
|
||||||
|
state=s.state or "on",
|
||||||
|
)
|
||||||
|
if s.condition_type == "mqtt":
|
||||||
|
return MQTTCondition(
|
||||||
|
topic=s.topic or "",
|
||||||
|
payload=s.payload or "",
|
||||||
|
match_mode=s.match_mode or "exact",
|
||||||
|
)
|
||||||
raise ValueError(f"Unknown condition type: {s.condition_type}")
|
raise ValueError(f"Unknown condition type: {s.condition_type}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
387
server/src/wled_controller/api/routes/scene_presets.py
Normal file
387
server/src/wled_controller/api/routes/scene_presets.py
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
"""Scene preset API routes — CRUD, capture, activate, recapture."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from wled_controller.api.auth import AuthRequired
|
||||||
|
from wled_controller.api.dependencies import (
|
||||||
|
get_device_store,
|
||||||
|
get_picture_target_store,
|
||||||
|
get_processor_manager,
|
||||||
|
get_profile_engine,
|
||||||
|
get_profile_store,
|
||||||
|
get_scene_preset_store,
|
||||||
|
)
|
||||||
|
from wled_controller.api.schemas.scene_presets import (
|
||||||
|
ActivateResponse,
|
||||||
|
ScenePresetCreate,
|
||||||
|
ScenePresetListResponse,
|
||||||
|
ScenePresetResponse,
|
||||||
|
ScenePresetUpdate,
|
||||||
|
)
|
||||||
|
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||||
|
from wled_controller.storage import DeviceStore
|
||||||
|
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||||
|
from wled_controller.storage.profile_store import ProfileStore
|
||||||
|
from wled_controller.storage.scene_preset import (
|
||||||
|
DeviceBrightnessSnapshot,
|
||||||
|
ProfileSnapshot,
|
||||||
|
ScenePreset,
|
||||||
|
TargetSnapshot,
|
||||||
|
)
|
||||||
|
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||||
|
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Helpers =====
|
||||||
|
|
||||||
|
def _capture_snapshot(
|
||||||
|
target_store: PictureTargetStore,
|
||||||
|
device_store: DeviceStore,
|
||||||
|
profile_store: ProfileStore,
|
||||||
|
processor_manager: ProcessorManager,
|
||||||
|
) -> tuple:
|
||||||
|
"""Capture current system state as snapshot lists."""
|
||||||
|
targets = []
|
||||||
|
for t in target_store.get_all_targets():
|
||||||
|
proc = processor_manager._processors.get(t.id)
|
||||||
|
running = proc.is_running if proc else False
|
||||||
|
targets.append(TargetSnapshot(
|
||||||
|
target_id=t.id,
|
||||||
|
running=running,
|
||||||
|
color_strip_source_id=getattr(t, "color_strip_source_id", ""),
|
||||||
|
brightness_value_source_id=getattr(t, "brightness_value_source_id", ""),
|
||||||
|
fps=getattr(t, "fps", 30),
|
||||||
|
auto_start=getattr(t, "auto_start", False),
|
||||||
|
))
|
||||||
|
|
||||||
|
devices = []
|
||||||
|
for d in device_store.get_all_devices():
|
||||||
|
devices.append(DeviceBrightnessSnapshot(
|
||||||
|
device_id=d.id,
|
||||||
|
software_brightness=getattr(d, "software_brightness", 255),
|
||||||
|
))
|
||||||
|
|
||||||
|
profiles = []
|
||||||
|
for p in profile_store.get_all_profiles():
|
||||||
|
profiles.append(ProfileSnapshot(
|
||||||
|
profile_id=p.id,
|
||||||
|
enabled=p.enabled,
|
||||||
|
))
|
||||||
|
|
||||||
|
return targets, devices, profiles
|
||||||
|
|
||||||
|
|
||||||
|
def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
|
||||||
|
return ScenePresetResponse(
|
||||||
|
id=preset.id,
|
||||||
|
name=preset.name,
|
||||||
|
description=preset.description,
|
||||||
|
color=preset.color,
|
||||||
|
targets=[{
|
||||||
|
"target_id": t.target_id,
|
||||||
|
"running": t.running,
|
||||||
|
"color_strip_source_id": t.color_strip_source_id,
|
||||||
|
"brightness_value_source_id": t.brightness_value_source_id,
|
||||||
|
"fps": t.fps,
|
||||||
|
"auto_start": t.auto_start,
|
||||||
|
} for t in preset.targets],
|
||||||
|
devices=[{
|
||||||
|
"device_id": d.device_id,
|
||||||
|
"software_brightness": d.software_brightness,
|
||||||
|
} for d in preset.devices],
|
||||||
|
profiles=[{
|
||||||
|
"profile_id": p.profile_id,
|
||||||
|
"enabled": p.enabled,
|
||||||
|
} for p in preset.profiles],
|
||||||
|
order=preset.order,
|
||||||
|
created_at=preset.created_at,
|
||||||
|
updated_at=preset.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== CRUD =====
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/scene-presets",
|
||||||
|
response_model=ScenePresetResponse,
|
||||||
|
tags=["Scene Presets"],
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def create_scene_preset(
|
||||||
|
data: ScenePresetCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
||||||
|
device_store: DeviceStore = Depends(get_device_store),
|
||||||
|
profile_store: ProfileStore = Depends(get_profile_store),
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
):
|
||||||
|
"""Capture current state as a new scene preset."""
|
||||||
|
targets, devices, profiles = _capture_snapshot(
|
||||||
|
target_store, device_store, profile_store, manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
preset = ScenePreset(
|
||||||
|
id=f"scene_{uuid.uuid4().hex[:8]}",
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
color=data.color,
|
||||||
|
targets=targets,
|
||||||
|
devices=devices,
|
||||||
|
profiles=profiles,
|
||||||
|
order=store.count(),
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
preset = store.create_preset(preset)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
return _preset_to_response(preset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/scene-presets",
|
||||||
|
response_model=ScenePresetListResponse,
|
||||||
|
tags=["Scene Presets"],
|
||||||
|
)
|
||||||
|
async def list_scene_presets(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
):
|
||||||
|
"""List all scene presets."""
|
||||||
|
presets = store.get_all_presets()
|
||||||
|
return ScenePresetListResponse(
|
||||||
|
presets=[_preset_to_response(p) for p in presets],
|
||||||
|
count=len(presets),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/scene-presets/{preset_id}",
|
||||||
|
response_model=ScenePresetResponse,
|
||||||
|
tags=["Scene Presets"],
|
||||||
|
)
|
||||||
|
async def get_scene_preset(
|
||||||
|
preset_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
):
|
||||||
|
"""Get a single scene preset."""
|
||||||
|
try:
|
||||||
|
preset = store.get_preset(preset_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
return _preset_to_response(preset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/scene-presets/{preset_id}",
|
||||||
|
response_model=ScenePresetResponse,
|
||||||
|
tags=["Scene Presets"],
|
||||||
|
)
|
||||||
|
async def update_scene_preset(
|
||||||
|
preset_id: str,
|
||||||
|
data: ScenePresetUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
):
|
||||||
|
"""Update scene preset metadata."""
|
||||||
|
try:
|
||||||
|
preset = store.update_preset(
|
||||||
|
preset_id,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
color=data.color,
|
||||||
|
order=data.order,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e))
|
||||||
|
return _preset_to_response(preset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/api/v1/scene-presets/{preset_id}",
|
||||||
|
status_code=204,
|
||||||
|
tags=["Scene Presets"],
|
||||||
|
)
|
||||||
|
async def delete_scene_preset(
|
||||||
|
preset_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
):
|
||||||
|
"""Delete a scene preset."""
|
||||||
|
try:
|
||||||
|
store.delete_preset(preset_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Recapture =====
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/scene-presets/{preset_id}/recapture",
|
||||||
|
response_model=ScenePresetResponse,
|
||||||
|
tags=["Scene Presets"],
|
||||||
|
)
|
||||||
|
async def recapture_scene_preset(
|
||||||
|
preset_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
||||||
|
device_store: DeviceStore = Depends(get_device_store),
|
||||||
|
profile_store: ProfileStore = Depends(get_profile_store),
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
):
|
||||||
|
"""Re-capture current state into an existing preset (updates snapshot)."""
|
||||||
|
targets, devices, profiles = _capture_snapshot(
|
||||||
|
target_store, device_store, profile_store, manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
new_snapshot = ScenePreset(
|
||||||
|
id=preset_id,
|
||||||
|
name="",
|
||||||
|
targets=targets,
|
||||||
|
devices=devices,
|
||||||
|
profiles=profiles,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
preset = store.recapture_preset(preset_id, new_snapshot)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
return _preset_to_response(preset)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Activate =====
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/scene-presets/{preset_id}/activate",
|
||||||
|
response_model=ActivateResponse,
|
||||||
|
tags=["Scene Presets"],
|
||||||
|
)
|
||||||
|
async def activate_scene_preset(
|
||||||
|
preset_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
||||||
|
device_store: DeviceStore = Depends(get_device_store),
|
||||||
|
profile_store: ProfileStore = Depends(get_profile_store),
|
||||||
|
engine: ProfileEngine = Depends(get_profile_engine),
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
):
|
||||||
|
"""Activate a scene preset — restore the captured state."""
|
||||||
|
try:
|
||||||
|
preset = store.get_preset(preset_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# 1. Toggle profile enable states
|
||||||
|
for ps in preset.profiles:
|
||||||
|
try:
|
||||||
|
p = profile_store.get_profile(ps.profile_id)
|
||||||
|
if p.enabled != ps.enabled:
|
||||||
|
if not ps.enabled:
|
||||||
|
await engine.deactivate_if_active(ps.profile_id)
|
||||||
|
profile_store.update_profile(ps.profile_id, enabled=ps.enabled)
|
||||||
|
except ValueError:
|
||||||
|
errors.append(f"Profile {ps.profile_id} not found (skipped)")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Profile {ps.profile_id}: {e}")
|
||||||
|
|
||||||
|
# 2. Stop targets that should be stopped
|
||||||
|
for ts in preset.targets:
|
||||||
|
if not ts.running:
|
||||||
|
try:
|
||||||
|
proc = manager._processors.get(ts.target_id)
|
||||||
|
if proc and proc.is_running:
|
||||||
|
await manager.stop_processing(ts.target_id)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Stop target {ts.target_id}: {e}")
|
||||||
|
|
||||||
|
# 3. Update target configs (CSS, brightness source, FPS)
|
||||||
|
for ts in preset.targets:
|
||||||
|
try:
|
||||||
|
target = target_store.get_target(ts.target_id)
|
||||||
|
changed = {}
|
||||||
|
if getattr(target, "color_strip_source_id", None) != ts.color_strip_source_id:
|
||||||
|
changed["color_strip_source_id"] = ts.color_strip_source_id
|
||||||
|
if getattr(target, "brightness_value_source_id", None) != ts.brightness_value_source_id:
|
||||||
|
changed["brightness_value_source_id"] = ts.brightness_value_source_id
|
||||||
|
if getattr(target, "fps", None) != ts.fps:
|
||||||
|
changed["fps"] = ts.fps
|
||||||
|
if getattr(target, "auto_start", None) != ts.auto_start:
|
||||||
|
changed["auto_start"] = ts.auto_start
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
target.update_fields(**changed)
|
||||||
|
target_store.update_target(ts.target_id, **changed)
|
||||||
|
|
||||||
|
# Sync live processor if running
|
||||||
|
proc = manager._processors.get(ts.target_id)
|
||||||
|
if proc and proc.is_running:
|
||||||
|
css_changed = "color_strip_source_id" in changed
|
||||||
|
bvs_changed = "brightness_value_source_id" in changed
|
||||||
|
settings_changed = "fps" in changed
|
||||||
|
if css_changed:
|
||||||
|
target.sync_with_manager(manager, settings_changed=False, css_changed=True)
|
||||||
|
if bvs_changed:
|
||||||
|
target.sync_with_manager(manager, settings_changed=False, brightness_vs_changed=True)
|
||||||
|
if settings_changed:
|
||||||
|
target.sync_with_manager(manager, settings_changed=True)
|
||||||
|
except ValueError:
|
||||||
|
errors.append(f"Target {ts.target_id} not found (skipped)")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Target {ts.target_id} config: {e}")
|
||||||
|
|
||||||
|
# 4. Start targets that should be running
|
||||||
|
for ts in preset.targets:
|
||||||
|
if ts.running:
|
||||||
|
try:
|
||||||
|
proc = manager._processors.get(ts.target_id)
|
||||||
|
if not proc or not proc.is_running:
|
||||||
|
await manager.start_processing(ts.target_id)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Start target {ts.target_id}: {e}")
|
||||||
|
|
||||||
|
# 5. Set device brightness
|
||||||
|
for ds in preset.devices:
|
||||||
|
try:
|
||||||
|
device = device_store.get_device(ds.device_id)
|
||||||
|
if device.software_brightness != ds.software_brightness:
|
||||||
|
device_store.update_device(ds.device_id, software_brightness=ds.software_brightness)
|
||||||
|
# Update live processor brightness
|
||||||
|
dev_state = manager._devices.get(ds.device_id)
|
||||||
|
if dev_state:
|
||||||
|
dev_state.software_brightness = ds.software_brightness
|
||||||
|
except ValueError:
|
||||||
|
errors.append(f"Device {ds.device_id} not found (skipped)")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Device {ds.device_id} brightness: {e}")
|
||||||
|
|
||||||
|
# Trigger profile re-evaluation after all changes
|
||||||
|
try:
|
||||||
|
await engine.trigger_evaluate()
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Profile re-evaluation: {e}")
|
||||||
|
|
||||||
|
status = "activated" if not errors else "partial"
|
||||||
|
if errors:
|
||||||
|
logger.warning(f"Scene preset {preset_id} activation errors: {errors}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Scene preset '{preset.name}' activated successfully")
|
||||||
|
|
||||||
|
return ActivateResponse(status=status, errors=errors)
|
||||||
@@ -272,6 +272,7 @@ STORE_MAP = {
|
|||||||
"audio_templates": "audio_templates_file",
|
"audio_templates": "audio_templates_file",
|
||||||
"value_sources": "value_sources_file",
|
"value_sources": "value_sources_file",
|
||||||
"profiles": "profiles_file",
|
"profiles": "profiles_file",
|
||||||
|
"scene_presets": "scene_presets_file",
|
||||||
}
|
}
|
||||||
|
|
||||||
_SERVER_DIR = Path(__file__).resolve().parents[4]
|
_SERVER_DIR = Path(__file__).resolve().parents[4]
|
||||||
|
|||||||
@@ -10,8 +10,21 @@ class ConditionSchema(BaseModel):
|
|||||||
"""A single condition within a profile."""
|
"""A single condition within a profile."""
|
||||||
|
|
||||||
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
|
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
|
||||||
|
# Application condition fields
|
||||||
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
|
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
|
||||||
match_type: Optional[str] = Field(None, description="'running' or 'topmost' (for application condition)")
|
match_type: Optional[str] = Field(None, description="'running' or 'topmost' (for application condition)")
|
||||||
|
# Time-of-day condition fields
|
||||||
|
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day condition)")
|
||||||
|
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day condition)")
|
||||||
|
# System idle condition fields
|
||||||
|
idle_minutes: Optional[int] = Field(None, description="Idle timeout in minutes (for system_idle condition)")
|
||||||
|
when_idle: Optional[bool] = Field(None, description="True=active when idle (for system_idle condition)")
|
||||||
|
# Display state condition fields
|
||||||
|
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state condition)")
|
||||||
|
# MQTT condition fields
|
||||||
|
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt condition)")
|
||||||
|
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt condition)")
|
||||||
|
match_mode: Optional[str] = Field(None, description="'exact', 'contains', or 'regex' (for mqtt condition)")
|
||||||
|
|
||||||
|
|
||||||
class ProfileCreate(BaseModel):
|
class ProfileCreate(BaseModel):
|
||||||
|
|||||||
67
server/src/wled_controller/api/schemas/scene_presets.py
Normal file
67
server/src/wled_controller/api/schemas/scene_presets.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Scene preset API schemas."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TargetSnapshotSchema(BaseModel):
|
||||||
|
target_id: str
|
||||||
|
running: bool = False
|
||||||
|
color_strip_source_id: str = ""
|
||||||
|
brightness_value_source_id: str = ""
|
||||||
|
fps: int = 30
|
||||||
|
auto_start: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceBrightnessSnapshotSchema(BaseModel):
|
||||||
|
device_id: str
|
||||||
|
software_brightness: int = 255
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileSnapshotSchema(BaseModel):
|
||||||
|
profile_id: str
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePresetCreate(BaseModel):
|
||||||
|
"""Create a scene preset by capturing current state."""
|
||||||
|
|
||||||
|
name: str = Field(description="Preset name", min_length=1, max_length=100)
|
||||||
|
description: str = Field(default="", max_length=500)
|
||||||
|
color: str = Field(default="#4fc3f7", description="Card accent color")
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePresetUpdate(BaseModel):
|
||||||
|
"""Update scene preset metadata (not snapshot data — use recapture for that)."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = Field(None, max_length=500)
|
||||||
|
color: Optional[str] = None
|
||||||
|
order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePresetResponse(BaseModel):
|
||||||
|
"""Scene preset with full snapshot data."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
color: str
|
||||||
|
targets: List[TargetSnapshotSchema]
|
||||||
|
devices: List[DeviceBrightnessSnapshotSchema]
|
||||||
|
profiles: List[ProfileSnapshotSchema]
|
||||||
|
order: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePresetListResponse(BaseModel):
|
||||||
|
presets: List[ScenePresetResponse]
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ActivateResponse(BaseModel):
|
||||||
|
status: str = Field(description="'activated' or 'partial'")
|
||||||
|
errors: List[str] = Field(default_factory=list)
|
||||||
@@ -38,6 +38,19 @@ class StorageConfig(BaseSettings):
|
|||||||
audio_templates_file: str = "data/audio_templates.json"
|
audio_templates_file: str = "data/audio_templates.json"
|
||||||
value_sources_file: str = "data/value_sources.json"
|
value_sources_file: str = "data/value_sources.json"
|
||||||
profiles_file: str = "data/profiles.json"
|
profiles_file: str = "data/profiles.json"
|
||||||
|
scene_presets_file: str = "data/scene_presets.json"
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTConfig(BaseSettings):
|
||||||
|
"""MQTT broker configuration."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
broker_host: str = "localhost"
|
||||||
|
broker_port: int = 1883
|
||||||
|
username: str = ""
|
||||||
|
password: str = ""
|
||||||
|
client_id: str = "ledgrab"
|
||||||
|
base_topic: str = "ledgrab"
|
||||||
|
|
||||||
|
|
||||||
class LoggingConfig(BaseSettings):
|
class LoggingConfig(BaseSettings):
|
||||||
@@ -61,6 +74,7 @@ class Config(BaseSettings):
|
|||||||
server: ServerConfig = Field(default_factory=ServerConfig)
|
server: ServerConfig = Field(default_factory=ServerConfig)
|
||||||
auth: AuthConfig = Field(default_factory=AuthConfig)
|
auth: AuthConfig = Field(default_factory=AuthConfig)
|
||||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||||
|
mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
|
||||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -282,5 +282,8 @@ def _register_builtin_providers():
|
|||||||
from wled_controller.core.devices.mock_provider import MockDeviceProvider
|
from wled_controller.core.devices.mock_provider import MockDeviceProvider
|
||||||
register_provider(MockDeviceProvider())
|
register_provider(MockDeviceProvider())
|
||||||
|
|
||||||
|
from wled_controller.core.devices.mqtt_provider import MQTTDeviceProvider
|
||||||
|
register_provider(MQTTDeviceProvider())
|
||||||
|
|
||||||
|
|
||||||
_register_builtin_providers()
|
_register_builtin_providers()
|
||||||
|
|||||||
98
server/src/wled_controller/core/devices/mqtt_client.py
Normal file
98
server/src/wled_controller/core/devices/mqtt_client.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""MQTT LED client — publishes pixel data to an MQTT topic."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Singleton reference — injected from main.py at startup
|
||||||
|
_mqtt_service = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_mqtt_service(service) -> None:
|
||||||
|
global _mqtt_service
|
||||||
|
_mqtt_service = service
|
||||||
|
|
||||||
|
|
||||||
|
def get_mqtt_service():
|
||||||
|
return _mqtt_service
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mqtt_url(url: str) -> str:
|
||||||
|
"""Extract topic from an mqtt:// URL.
|
||||||
|
|
||||||
|
Format: mqtt://topic/path (broker connection is global via config)
|
||||||
|
"""
|
||||||
|
if url.startswith("mqtt://"):
|
||||||
|
return url[7:]
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTLEDClient(LEDClient):
|
||||||
|
"""Publishes JSON pixel data to an MQTT topic via the shared service."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, led_count: int = 0, **kwargs):
|
||||||
|
self._topic = parse_mqtt_url(url)
|
||||||
|
self._led_count = led_count
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
svc = _mqtt_service
|
||||||
|
if svc is None or not svc.is_enabled:
|
||||||
|
raise ConnectionError("MQTT service not available")
|
||||||
|
if not svc.is_connected:
|
||||||
|
raise ConnectionError("MQTT service not connected to broker")
|
||||||
|
self._connected = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return self._connected and _mqtt_service is not None and _mqtt_service.is_connected
|
||||||
|
|
||||||
|
async def send_pixels(
|
||||||
|
self,
|
||||||
|
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||||
|
brightness: int = 255,
|
||||||
|
) -> bool:
|
||||||
|
svc = _mqtt_service
|
||||||
|
if svc is None or not svc.is_connected:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if isinstance(pixels, np.ndarray):
|
||||||
|
pixel_list = pixels.tolist()
|
||||||
|
else:
|
||||||
|
pixel_list = list(pixels)
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"pixels": pixel_list,
|
||||||
|
"brightness": brightness,
|
||||||
|
"led_count": len(pixel_list),
|
||||||
|
})
|
||||||
|
|
||||||
|
await svc.publish(self._topic, payload, retain=False, qos=0)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def check_health(
|
||||||
|
cls,
|
||||||
|
url: str,
|
||||||
|
http_client,
|
||||||
|
prev_health=None,
|
||||||
|
) -> DeviceHealth:
|
||||||
|
from datetime import datetime
|
||||||
|
svc = _mqtt_service
|
||||||
|
if svc is None or not svc.is_enabled:
|
||||||
|
return DeviceHealth(online=False, error="MQTT disabled", last_checked=datetime.utcnow())
|
||||||
|
return DeviceHealth(
|
||||||
|
online=svc.is_connected,
|
||||||
|
last_checked=datetime.utcnow(),
|
||||||
|
error=None if svc.is_connected else "MQTT broker disconnected",
|
||||||
|
)
|
||||||
51
server/src/wled_controller/core/devices/mqtt_provider.py
Normal file
51
server/src/wled_controller/core/devices/mqtt_provider.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""MQTT device provider — factory, validation, health checks."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
from wled_controller.core.devices.led_client import (
|
||||||
|
DeviceHealth,
|
||||||
|
DiscoveredDevice,
|
||||||
|
LEDClient,
|
||||||
|
LEDDeviceProvider,
|
||||||
|
)
|
||||||
|
from wled_controller.core.devices.mqtt_client import (
|
||||||
|
MQTTLEDClient,
|
||||||
|
get_mqtt_service,
|
||||||
|
parse_mqtt_url,
|
||||||
|
)
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTDeviceProvider(LEDDeviceProvider):
|
||||||
|
"""Provider for MQTT-based LED devices."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_type(self) -> str:
|
||||||
|
return "mqtt"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set:
|
||||||
|
return {"manual_led_count"}
|
||||||
|
|
||||||
|
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||||
|
return MQTTLEDClient(url, **kwargs)
|
||||||
|
|
||||||
|
async def check_health(
|
||||||
|
self, url: str, http_client, prev_health=None,
|
||||||
|
) -> DeviceHealth:
|
||||||
|
return await MQTTLEDClient.check_health(url, http_client, prev_health)
|
||||||
|
|
||||||
|
async def validate_device(self, url: str) -> dict:
|
||||||
|
"""Validate MQTT device URL (topic path)."""
|
||||||
|
topic = parse_mqtt_url(url)
|
||||||
|
if not topic or topic == "/":
|
||||||
|
raise ValueError("MQTT topic cannot be empty")
|
||||||
|
# Can't auto-detect LED count — require manual entry
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||||
|
# MQTT devices are not auto-discoverable
|
||||||
|
return []
|
||||||
0
server/src/wled_controller/core/mqtt/__init__.py
Normal file
0
server/src/wled_controller/core/mqtt/__init__.py
Normal file
176
server/src/wled_controller/core/mqtt/mqtt_service.py
Normal file
176
server/src/wled_controller/core/mqtt/mqtt_service.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""Singleton async MQTT service — shared broker connection for all features."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Callable, Dict, Optional, Set
|
||||||
|
|
||||||
|
import aiomqtt
|
||||||
|
|
||||||
|
from wled_controller.config import MQTTConfig
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTService:
|
||||||
|
"""Manages a persistent MQTT broker connection.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Publish messages (retained or transient)
|
||||||
|
- Subscribe to topics with callback dispatch
|
||||||
|
- Topic value cache for synchronous reads (profile condition evaluation)
|
||||||
|
- Auto-reconnect loop
|
||||||
|
- Birth / will messages for online status
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: MQTTConfig):
|
||||||
|
self._config = config
|
||||||
|
self._client: Optional[aiomqtt.Client] = None
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
# Subscription management
|
||||||
|
self._subscriptions: Dict[str, Set[Callable]] = {} # topic -> set of callbacks
|
||||||
|
self._topic_cache: Dict[str, str] = {} # topic -> last payload string
|
||||||
|
|
||||||
|
# Pending publishes queued while disconnected
|
||||||
|
self._publish_queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
return self._config.enabled
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if not self._config.enabled:
|
||||||
|
logger.info("MQTT service disabled in configuration")
|
||||||
|
return
|
||||||
|
if self._task is not None:
|
||||||
|
return
|
||||||
|
self._task = asyncio.create_task(self._connection_loop())
|
||||||
|
logger.info(f"MQTT service starting — broker {self._config.broker_host}:{self._config.broker_port}")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._task is None:
|
||||||
|
return
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._task = None
|
||||||
|
self._connected = False
|
||||||
|
logger.info("MQTT service stopped")
|
||||||
|
|
||||||
|
async def publish(self, topic: str, payload: str, retain: bool = False, qos: int = 0) -> None:
|
||||||
|
"""Publish a message. If disconnected, queue for later delivery."""
|
||||||
|
if not self._config.enabled:
|
||||||
|
return
|
||||||
|
if self._connected and self._client:
|
||||||
|
try:
|
||||||
|
await self._client.publish(topic, payload, retain=retain, qos=qos)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"MQTT publish failed ({topic}): {e}")
|
||||||
|
# Queue for retry
|
||||||
|
try:
|
||||||
|
self._publish_queue.put_nowait((topic, payload, retain, qos))
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def subscribe(self, topic: str, callback: Callable) -> None:
|
||||||
|
"""Subscribe to a topic. Callback receives (topic: str, payload: str)."""
|
||||||
|
if topic not in self._subscriptions:
|
||||||
|
self._subscriptions[topic] = set()
|
||||||
|
self._subscriptions[topic].add(callback)
|
||||||
|
|
||||||
|
# Subscribe on the live client if connected
|
||||||
|
if self._connected and self._client:
|
||||||
|
try:
|
||||||
|
await self._client.subscribe(topic)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"MQTT subscribe failed ({topic}): {e}")
|
||||||
|
|
||||||
|
def get_last_value(self, topic: str) -> Optional[str]:
|
||||||
|
"""Get cached last value for a topic (synchronous — for profile evaluation)."""
|
||||||
|
return self._topic_cache.get(topic)
|
||||||
|
|
||||||
|
async def _connection_loop(self) -> None:
|
||||||
|
"""Persistent connection loop with auto-reconnect."""
|
||||||
|
base_topic = self._config.base_topic
|
||||||
|
will_topic = f"{base_topic}/status"
|
||||||
|
will_payload = "offline"
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async with aiomqtt.Client(
|
||||||
|
hostname=self._config.broker_host,
|
||||||
|
port=self._config.broker_port,
|
||||||
|
username=self._config.username or None,
|
||||||
|
password=self._config.password or None,
|
||||||
|
identifier=self._config.client_id,
|
||||||
|
will=aiomqtt.Will(
|
||||||
|
topic=will_topic,
|
||||||
|
payload=will_payload,
|
||||||
|
retain=True,
|
||||||
|
),
|
||||||
|
) as client:
|
||||||
|
self._client = client
|
||||||
|
self._connected = True
|
||||||
|
logger.info("MQTT connected to broker")
|
||||||
|
|
||||||
|
# Publish birth message
|
||||||
|
await client.publish(will_topic, "online", retain=True)
|
||||||
|
|
||||||
|
# Re-subscribe to all registered topics
|
||||||
|
for topic in self._subscriptions:
|
||||||
|
await client.subscribe(topic)
|
||||||
|
|
||||||
|
# Drain pending publishes
|
||||||
|
while not self._publish_queue.empty():
|
||||||
|
try:
|
||||||
|
t, p, r, q = self._publish_queue.get_nowait()
|
||||||
|
await client.publish(t, p, retain=r, qos=q)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Message receive loop
|
||||||
|
async for msg in client.messages:
|
||||||
|
topic_str = str(msg.topic)
|
||||||
|
payload_str = msg.payload.decode("utf-8", errors="replace") if msg.payload else ""
|
||||||
|
self._topic_cache[topic_str] = payload_str
|
||||||
|
|
||||||
|
# Dispatch to callbacks
|
||||||
|
for sub_topic, callbacks in self._subscriptions.items():
|
||||||
|
if aiomqtt.Topic(sub_topic).matches(msg.topic):
|
||||||
|
for cb in callbacks:
|
||||||
|
try:
|
||||||
|
if asyncio.iscoroutinefunction(cb):
|
||||||
|
asyncio.create_task(cb(topic_str, payload_str))
|
||||||
|
else:
|
||||||
|
cb(topic_str, payload_str)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MQTT callback error ({topic_str}): {e}")
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
self._connected = False
|
||||||
|
self._client = None
|
||||||
|
logger.warning(f"MQTT connection lost: {e}. Reconnecting in 5s...")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
# ===== State exposure helpers =====
|
||||||
|
|
||||||
|
async def publish_target_state(self, target_id: str, state: dict) -> None:
|
||||||
|
"""Publish target state to MQTT (called from event handler)."""
|
||||||
|
topic = f"{self._config.base_topic}/target/{target_id}/state"
|
||||||
|
await self.publish(topic, json.dumps(state), retain=True)
|
||||||
|
|
||||||
|
async def publish_profile_state(self, profile_id: str, action: str) -> None:
|
||||||
|
"""Publish profile state change to MQTT."""
|
||||||
|
topic = f"{self._config.base_topic}/profile/{profile_id}/state"
|
||||||
|
await self.publish(topic, json.dumps({"action": action}), retain=True)
|
||||||
@@ -9,6 +9,7 @@ import ctypes
|
|||||||
import ctypes.wintypes
|
import ctypes.wintypes
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
from typing import Optional, Set
|
from typing import Optional, Set
|
||||||
|
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
@@ -21,6 +22,148 @@ _IS_WINDOWS = sys.platform == "win32"
|
|||||||
class PlatformDetector:
|
class PlatformDetector:
|
||||||
"""Detect running processes and the foreground window's process."""
|
"""Detect running processes and the foreground window's process."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._display_on: bool = True
|
||||||
|
self._display_listener_started = False
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
t = threading.Thread(target=self._display_power_listener, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
# ---- Display power state (event-driven) ----
|
||||||
|
|
||||||
|
def _display_power_listener(self) -> None:
|
||||||
|
"""Background thread: hidden window that receives display power events."""
|
||||||
|
try:
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
|
||||||
|
WNDPROC = ctypes.WINFUNCTYPE(
|
||||||
|
ctypes.c_long,
|
||||||
|
ctypes.wintypes.HWND,
|
||||||
|
ctypes.c_uint,
|
||||||
|
ctypes.wintypes.WPARAM,
|
||||||
|
ctypes.wintypes.LPARAM,
|
||||||
|
)
|
||||||
|
|
||||||
|
WM_POWERBROADCAST = 0x0218
|
||||||
|
PBT_POWERSETTINGCHANGE = 0x8013
|
||||||
|
|
||||||
|
class POWERBROADCAST_SETTING(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("PowerSetting", ctypes.c_ubyte * 16), # GUID
|
||||||
|
("DataLength", ctypes.wintypes.DWORD),
|
||||||
|
("Data", ctypes.c_ubyte * 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
# GUID_CONSOLE_DISPLAY_STATE = {6FE69556-704A-47A0-8F24-C28D936FDA47}
|
||||||
|
GUID_CONSOLE_DISPLAY_STATE = (ctypes.c_ubyte * 16)(
|
||||||
|
0x56, 0x95, 0xE6, 0x6F, 0x4A, 0x70, 0xA0, 0x47,
|
||||||
|
0x8F, 0x24, 0xC2, 0x8D, 0x93, 0x6F, 0xDA, 0x47,
|
||||||
|
)
|
||||||
|
|
||||||
|
def wnd_proc(hwnd, msg, wparam, lparam):
|
||||||
|
if msg == WM_POWERBROADCAST and wparam == PBT_POWERSETTINGCHANGE:
|
||||||
|
try:
|
||||||
|
setting = ctypes.cast(
|
||||||
|
lparam, ctypes.POINTER(POWERBROADCAST_SETTING)
|
||||||
|
).contents
|
||||||
|
# Data: 0=off, 1=on, 2=dimmed (treat dimmed as on)
|
||||||
|
value = setting.Data[0]
|
||||||
|
self._display_on = value != 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
return user32.DefWindowProcW(hwnd, msg, wparam, lparam)
|
||||||
|
|
||||||
|
wnd_proc_cb = WNDPROC(wnd_proc)
|
||||||
|
|
||||||
|
# Register window class
|
||||||
|
class WNDCLASSEXW(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("cbSize", ctypes.c_uint),
|
||||||
|
("style", ctypes.c_uint),
|
||||||
|
("lpfnWndProc", WNDPROC),
|
||||||
|
("cbClsExtra", ctypes.c_int),
|
||||||
|
("cbWndExtra", ctypes.c_int),
|
||||||
|
("hInstance", ctypes.wintypes.HINSTANCE),
|
||||||
|
("hIcon", ctypes.wintypes.HICON),
|
||||||
|
("hCursor", ctypes.wintypes.HANDLE),
|
||||||
|
("hbrBackground", ctypes.wintypes.HBRUSH),
|
||||||
|
("lpszMenuName", ctypes.wintypes.LPCWSTR),
|
||||||
|
("lpszClassName", ctypes.wintypes.LPCWSTR),
|
||||||
|
("hIconSm", ctypes.wintypes.HICON),
|
||||||
|
]
|
||||||
|
|
||||||
|
wc = WNDCLASSEXW()
|
||||||
|
wc.cbSize = ctypes.sizeof(WNDCLASSEXW)
|
||||||
|
wc.lpfnWndProc = wnd_proc_cb
|
||||||
|
wc.lpszClassName = "LedGrabDisplayMonitor"
|
||||||
|
wc.hInstance = ctypes.windll.kernel32.GetModuleHandleW(None)
|
||||||
|
|
||||||
|
atom = user32.RegisterClassExW(ctypes.byref(wc))
|
||||||
|
if not atom:
|
||||||
|
logger.warning("Failed to register display monitor window class")
|
||||||
|
return
|
||||||
|
|
||||||
|
HWND_MESSAGE = ctypes.wintypes.HWND(-3)
|
||||||
|
hwnd = user32.CreateWindowExW(
|
||||||
|
0, wc.lpszClassName, "LedGrab Display Monitor",
|
||||||
|
0, 0, 0, 0, 0, HWND_MESSAGE, None, wc.hInstance, None,
|
||||||
|
)
|
||||||
|
if not hwnd:
|
||||||
|
logger.warning("Failed to create display monitor hidden window")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Register for display power notifications
|
||||||
|
user32.RegisterPowerSettingNotification(
|
||||||
|
hwnd, ctypes.byref(GUID_CONSOLE_DISPLAY_STATE), 0
|
||||||
|
)
|
||||||
|
|
||||||
|
self._display_listener_started = True
|
||||||
|
logger.debug("Display power listener started")
|
||||||
|
|
||||||
|
# Message pump
|
||||||
|
msg = ctypes.wintypes.MSG()
|
||||||
|
while user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0:
|
||||||
|
user32.TranslateMessage(ctypes.byref(msg))
|
||||||
|
user32.DispatchMessageW(ctypes.byref(msg))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Display power listener failed: {e}")
|
||||||
|
|
||||||
|
def _get_display_power_state_sync(self) -> Optional[str]:
|
||||||
|
"""Get display power state: 'on' or 'off'. Returns None if unavailable."""
|
||||||
|
if not _IS_WINDOWS:
|
||||||
|
return None
|
||||||
|
return "on" if self._display_on else "off"
|
||||||
|
|
||||||
|
# ---- System idle detection ----
|
||||||
|
|
||||||
|
def _get_idle_seconds_sync(self) -> Optional[float]:
|
||||||
|
"""Get system idle time in seconds (keyboard/mouse inactivity).
|
||||||
|
|
||||||
|
Returns None if detection is unavailable.
|
||||||
|
"""
|
||||||
|
if not _IS_WINDOWS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
class LASTINPUTINFO(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("cbSize", ctypes.c_uint),
|
||||||
|
("dwTime", ctypes.c_uint),
|
||||||
|
]
|
||||||
|
|
||||||
|
lii = LASTINPUTINFO()
|
||||||
|
lii.cbSize = ctypes.sizeof(LASTINPUTINFO)
|
||||||
|
if not ctypes.windll.user32.GetLastInputInfo(ctypes.byref(lii)):
|
||||||
|
return None
|
||||||
|
millis = ctypes.windll.kernel32.GetTickCount() - lii.dwTime
|
||||||
|
return millis / 1000.0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get idle time: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ---- Process detection ----
|
||||||
|
|
||||||
def _get_running_processes_sync(self) -> Set[str]:
|
def _get_running_processes_sync(self) -> Set[str]:
|
||||||
"""Get set of lowercase process names via Win32 EnumProcesses.
|
"""Get set of lowercase process names via Win32 EnumProcesses.
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
"""Profile engine — background loop that evaluates conditions and manages targets."""
|
"""Profile engine — background loop that evaluates conditions and manages targets."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, Optional, Set
|
from typing import Dict, Optional, Set
|
||||||
|
|
||||||
from wled_controller.core.profiles.platform_detector import PlatformDetector
|
from wled_controller.core.profiles.platform_detector import PlatformDetector
|
||||||
from wled_controller.storage.profile import AlwaysCondition, ApplicationCondition, Condition, Profile
|
from wled_controller.storage.profile import (
|
||||||
|
AlwaysCondition,
|
||||||
|
ApplicationCondition,
|
||||||
|
Condition,
|
||||||
|
DisplayStateCondition,
|
||||||
|
MQTTCondition,
|
||||||
|
Profile,
|
||||||
|
SystemIdleCondition,
|
||||||
|
TimeOfDayCondition,
|
||||||
|
)
|
||||||
from wled_controller.storage.profile_store import ProfileStore
|
from wled_controller.storage.profile_store import ProfileStore
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
@@ -15,11 +25,13 @@ logger = get_logger(__name__)
|
|||||||
class ProfileEngine:
|
class ProfileEngine:
|
||||||
"""Evaluates profile conditions and starts/stops targets accordingly."""
|
"""Evaluates profile conditions and starts/stops targets accordingly."""
|
||||||
|
|
||||||
def __init__(self, profile_store: ProfileStore, processor_manager, poll_interval: float = 1.0):
|
def __init__(self, profile_store: ProfileStore, processor_manager, poll_interval: float = 1.0,
|
||||||
|
mqtt_service=None):
|
||||||
self._store = profile_store
|
self._store = profile_store
|
||||||
self._manager = processor_manager
|
self._manager = processor_manager
|
||||||
self._poll_interval = poll_interval
|
self._poll_interval = poll_interval
|
||||||
self._detector = PlatformDetector()
|
self._detector = PlatformDetector()
|
||||||
|
self._mqtt_service = mqtt_service
|
||||||
self._task: Optional[asyncio.Task] = None
|
self._task: Optional[asyncio.Task] = None
|
||||||
self._eval_lock = asyncio.Lock()
|
self._eval_lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -70,11 +82,12 @@ class ProfileEngine:
|
|||||||
|
|
||||||
def _detect_all_sync(
|
def _detect_all_sync(
|
||||||
self, needs_running: bool, needs_topmost: bool, needs_fullscreen: bool,
|
self, needs_running: bool, needs_topmost: bool, needs_fullscreen: bool,
|
||||||
|
needs_idle: bool, needs_display_state: bool,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""Run all platform detection in a single thread call.
|
"""Run all platform detection in a single thread call.
|
||||||
|
|
||||||
Batching the three detection calls into one executor submission reduces
|
Batching detection calls into one executor submission reduces
|
||||||
event-loop wake-ups from 3 to 1, minimising asyncio.sleep() jitter in
|
event-loop wake-ups, minimising asyncio.sleep() jitter in
|
||||||
latency-sensitive processing loops.
|
latency-sensitive processing loops.
|
||||||
"""
|
"""
|
||||||
running_procs = self._detector._get_running_processes_sync() if needs_running else set()
|
running_procs = self._detector._get_running_processes_sync() if needs_running else set()
|
||||||
@@ -83,7 +96,9 @@ class ProfileEngine:
|
|||||||
else:
|
else:
|
||||||
topmost_proc, topmost_fullscreen = None, False
|
topmost_proc, topmost_fullscreen = None, False
|
||||||
fullscreen_procs = self._detector._get_fullscreen_processes_sync() if needs_fullscreen else set()
|
fullscreen_procs = self._detector._get_fullscreen_processes_sync() if needs_fullscreen else set()
|
||||||
return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs
|
idle_seconds = self._detector._get_idle_seconds_sync() if needs_idle else None
|
||||||
|
display_state = self._detector._get_display_power_state_sync() if needs_display_state else None
|
||||||
|
return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs, idle_seconds, display_state
|
||||||
|
|
||||||
async def _evaluate_all_locked(self) -> None:
|
async def _evaluate_all_locked(self) -> None:
|
||||||
profiles = self._store.get_all_profiles()
|
profiles = self._store.get_all_profiles()
|
||||||
@@ -95,23 +110,30 @@ class ProfileEngine:
|
|||||||
|
|
||||||
# Determine which detection methods are actually needed
|
# Determine which detection methods are actually needed
|
||||||
match_types_used: set = set()
|
match_types_used: set = set()
|
||||||
|
needs_idle = False
|
||||||
|
needs_display_state = False
|
||||||
for p in profiles:
|
for p in profiles:
|
||||||
if p.enabled:
|
if p.enabled:
|
||||||
for c in p.conditions:
|
for c in p.conditions:
|
||||||
mt = getattr(c, "match_type", "running")
|
if isinstance(c, ApplicationCondition):
|
||||||
match_types_used.add(mt)
|
match_types_used.add(c.match_type)
|
||||||
|
elif isinstance(c, SystemIdleCondition):
|
||||||
|
needs_idle = True
|
||||||
|
elif isinstance(c, DisplayStateCondition):
|
||||||
|
needs_display_state = True
|
||||||
|
|
||||||
needs_running = "running" in match_types_used
|
needs_running = "running" in match_types_used
|
||||||
needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"})
|
needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"})
|
||||||
needs_fullscreen = "fullscreen" in match_types_used
|
needs_fullscreen = "fullscreen" in match_types_used
|
||||||
|
|
||||||
# Single executor call for all platform detection (avoids 3 separate
|
# Single executor call for all platform detection
|
||||||
# event-loop roundtrips that can jitter processing-loop timing)
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs = (
|
(running_procs, topmost_proc, topmost_fullscreen,
|
||||||
|
fullscreen_procs, idle_seconds, display_state) = (
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(
|
||||||
None, self._detect_all_sync,
|
None, self._detect_all_sync,
|
||||||
needs_running, needs_topmost, needs_fullscreen,
|
needs_running, needs_topmost, needs_fullscreen,
|
||||||
|
needs_idle, needs_display_state,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,7 +143,9 @@ class ProfileEngine:
|
|||||||
should_be_active = (
|
should_be_active = (
|
||||||
profile.enabled
|
profile.enabled
|
||||||
and (len(profile.conditions) == 0
|
and (len(profile.conditions) == 0
|
||||||
or self._evaluate_conditions(profile, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs))
|
or self._evaluate_conditions(
|
||||||
|
profile, running_procs, topmost_proc, topmost_fullscreen,
|
||||||
|
fullscreen_procs, idle_seconds, display_state))
|
||||||
)
|
)
|
||||||
|
|
||||||
is_active = profile.id in self._active_profiles
|
is_active = profile.id in self._active_profiles
|
||||||
@@ -143,9 +167,13 @@ class ProfileEngine:
|
|||||||
self, profile: Profile, running_procs: Set[str],
|
self, profile: Profile, running_procs: Set[str],
|
||||||
topmost_proc: Optional[str], topmost_fullscreen: bool,
|
topmost_proc: Optional[str], topmost_fullscreen: bool,
|
||||||
fullscreen_procs: Set[str],
|
fullscreen_procs: Set[str],
|
||||||
|
idle_seconds: Optional[float], display_state: Optional[str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
results = [
|
results = [
|
||||||
self._evaluate_condition(c, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs)
|
self._evaluate_condition(
|
||||||
|
c, running_procs, topmost_proc, topmost_fullscreen,
|
||||||
|
fullscreen_procs, idle_seconds, display_state,
|
||||||
|
)
|
||||||
for c in profile.conditions
|
for c in profile.conditions
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -157,11 +185,63 @@ class ProfileEngine:
|
|||||||
self, condition: Condition, running_procs: Set[str],
|
self, condition: Condition, running_procs: Set[str],
|
||||||
topmost_proc: Optional[str], topmost_fullscreen: bool,
|
topmost_proc: Optional[str], topmost_fullscreen: bool,
|
||||||
fullscreen_procs: Set[str],
|
fullscreen_procs: Set[str],
|
||||||
|
idle_seconds: Optional[float], display_state: Optional[str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if isinstance(condition, AlwaysCondition):
|
if isinstance(condition, AlwaysCondition):
|
||||||
return True
|
return True
|
||||||
if isinstance(condition, ApplicationCondition):
|
if isinstance(condition, ApplicationCondition):
|
||||||
return self._evaluate_app_condition(condition, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs)
|
return self._evaluate_app_condition(condition, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs)
|
||||||
|
if isinstance(condition, TimeOfDayCondition):
|
||||||
|
return self._evaluate_time_of_day(condition)
|
||||||
|
if isinstance(condition, SystemIdleCondition):
|
||||||
|
return self._evaluate_idle(condition, idle_seconds)
|
||||||
|
if isinstance(condition, DisplayStateCondition):
|
||||||
|
return self._evaluate_display_state(condition, display_state)
|
||||||
|
if isinstance(condition, MQTTCondition):
|
||||||
|
return self._evaluate_mqtt(condition)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _evaluate_time_of_day(condition: TimeOfDayCondition) -> bool:
|
||||||
|
now = datetime.now()
|
||||||
|
current = now.hour * 60 + now.minute
|
||||||
|
parts_s = condition.start_time.split(":")
|
||||||
|
parts_e = condition.end_time.split(":")
|
||||||
|
start = int(parts_s[0]) * 60 + int(parts_s[1])
|
||||||
|
end = int(parts_e[0]) * 60 + int(parts_e[1])
|
||||||
|
if start <= end:
|
||||||
|
return start <= current <= end
|
||||||
|
# Overnight range (e.g. 22:00 → 06:00)
|
||||||
|
return current >= start or current <= end
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _evaluate_idle(condition: SystemIdleCondition, idle_seconds: Optional[float]) -> bool:
|
||||||
|
if idle_seconds is None:
|
||||||
|
return False
|
||||||
|
is_idle = idle_seconds >= (condition.idle_minutes * 60)
|
||||||
|
return is_idle if condition.when_idle else not is_idle
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _evaluate_display_state(condition: DisplayStateCondition, display_state: Optional[str]) -> bool:
|
||||||
|
if display_state is None:
|
||||||
|
return False
|
||||||
|
return display_state == condition.state
|
||||||
|
|
||||||
|
def _evaluate_mqtt(self, condition: MQTTCondition) -> bool:
|
||||||
|
if self._mqtt_service is None or not self._mqtt_service.is_connected:
|
||||||
|
return False
|
||||||
|
value = self._mqtt_service.get_last_value(condition.topic)
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
if condition.match_mode == "exact":
|
||||||
|
return value == condition.payload
|
||||||
|
if condition.match_mode == "contains":
|
||||||
|
return condition.payload in value
|
||||||
|
if condition.match_mode == "regex":
|
||||||
|
try:
|
||||||
|
return bool(re.search(condition.payload, value))
|
||||||
|
except re.error:
|
||||||
|
return False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _evaluate_app_condition(
|
def _evaluate_app_condition(
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ from wled_controller.storage.audio_template_store import AudioTemplateStore
|
|||||||
import wled_controller.core.audio # noqa: F401 — trigger engine auto-registration
|
import wled_controller.core.audio # noqa: F401 — trigger engine auto-registration
|
||||||
from wled_controller.storage.value_source_store import ValueSourceStore
|
from wled_controller.storage.value_source_store import ValueSourceStore
|
||||||
from wled_controller.storage.profile_store import ProfileStore
|
from wled_controller.storage.profile_store import ProfileStore
|
||||||
|
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||||
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
||||||
|
from wled_controller.core.mqtt.mqtt_service import MQTTService
|
||||||
|
from wled_controller.core.devices.mqtt_client import set_mqtt_service
|
||||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||||
from wled_controller.api.routes.system import STORE_MAP
|
from wled_controller.api.routes.system import STORE_MAP
|
||||||
from wled_controller.utils import setup_logging, get_logger
|
from wled_controller.utils import setup_logging, get_logger
|
||||||
@@ -52,6 +55,7 @@ audio_source_store = AudioSourceStore(config.storage.audio_sources_file)
|
|||||||
audio_template_store = AudioTemplateStore(config.storage.audio_templates_file)
|
audio_template_store = AudioTemplateStore(config.storage.audio_templates_file)
|
||||||
value_source_store = ValueSourceStore(config.storage.value_sources_file)
|
value_source_store = ValueSourceStore(config.storage.value_sources_file)
|
||||||
profile_store = ProfileStore(config.storage.profiles_file)
|
profile_store = ProfileStore(config.storage.profiles_file)
|
||||||
|
scene_preset_store = ScenePresetStore(config.storage.scene_presets_file)
|
||||||
|
|
||||||
# Migrate embedded audio config from CSS entities to audio sources
|
# Migrate embedded audio config from CSS entities to audio sources
|
||||||
audio_source_store.migrate_from_css(color_strip_store)
|
audio_source_store.migrate_from_css(color_strip_store)
|
||||||
@@ -100,8 +104,12 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info(f"Authorized clients: {client_labels}")
|
logger.info(f"Authorized clients: {client_labels}")
|
||||||
logger.info("All API requests require valid Bearer token authentication")
|
logger.info("All API requests require valid Bearer token authentication")
|
||||||
|
|
||||||
# Create profile engine (needs processor_manager)
|
# Create MQTT service (shared broker connection)
|
||||||
profile_engine = ProfileEngine(profile_store, processor_manager)
|
mqtt_service = MQTTService(config.mqtt)
|
||||||
|
set_mqtt_service(mqtt_service)
|
||||||
|
|
||||||
|
# Create profile engine (needs processor_manager + mqtt_service)
|
||||||
|
profile_engine = ProfileEngine(profile_store, processor_manager, mqtt_service=mqtt_service)
|
||||||
|
|
||||||
# Create auto-backup engine
|
# Create auto-backup engine
|
||||||
auto_backup_engine = AutoBackupEngine(
|
auto_backup_engine = AutoBackupEngine(
|
||||||
@@ -123,6 +131,7 @@ async def lifespan(app: FastAPI):
|
|||||||
audio_template_store=audio_template_store,
|
audio_template_store=audio_template_store,
|
||||||
value_source_store=value_source_store,
|
value_source_store=value_source_store,
|
||||||
profile_store=profile_store,
|
profile_store=profile_store,
|
||||||
|
scene_preset_store=scene_preset_store,
|
||||||
profile_engine=profile_engine,
|
profile_engine=profile_engine,
|
||||||
auto_backup_engine=auto_backup_engine,
|
auto_backup_engine=auto_backup_engine,
|
||||||
)
|
)
|
||||||
@@ -162,6 +171,9 @@ async def lifespan(app: FastAPI):
|
|||||||
# Start background health monitoring for all devices
|
# Start background health monitoring for all devices
|
||||||
await processor_manager.start_health_monitoring()
|
await processor_manager.start_health_monitoring()
|
||||||
|
|
||||||
|
# Start MQTT service (broker connection for output, triggers, state)
|
||||||
|
await mqtt_service.start()
|
||||||
|
|
||||||
# Start profile engine (evaluates conditions and auto-starts/stops targets)
|
# Start profile engine (evaluates conditions and auto-starts/stops targets)
|
||||||
await profile_engine.start()
|
await profile_engine.start()
|
||||||
|
|
||||||
@@ -206,6 +218,12 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping processors: {e}")
|
logger.error(f"Error stopping processors: {e}")
|
||||||
|
|
||||||
|
# Stop MQTT service
|
||||||
|
try:
|
||||||
|
await mqtt_service.stop()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping MQTT service: {e}")
|
||||||
|
|
||||||
# Create FastAPI application
|
# Create FastAPI application
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LED Grab",
|
title="LED Grab",
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ import {
|
|||||||
toggleProfileEnabled, toggleProfileTargets, deleteProfile,
|
toggleProfileEnabled, toggleProfileTargets, deleteProfile,
|
||||||
expandAllProfileSections, collapseAllProfileSections,
|
expandAllProfileSections, collapseAllProfileSections,
|
||||||
} from './features/profiles.js';
|
} from './features/profiles.js';
|
||||||
|
import {
|
||||||
|
loadScenes, expandAllSceneSections, collapseAllSceneSections,
|
||||||
|
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
|
||||||
|
activateScenePreset, recaptureScenePreset, deleteScenePreset,
|
||||||
|
} from './features/scene-presets.js';
|
||||||
|
|
||||||
// Layer 5: device-discovery, targets
|
// Layer 5: device-discovery, targets
|
||||||
import {
|
import {
|
||||||
@@ -307,6 +312,18 @@ Object.assign(window, {
|
|||||||
expandAllProfileSections,
|
expandAllProfileSections,
|
||||||
collapseAllProfileSections,
|
collapseAllProfileSections,
|
||||||
|
|
||||||
|
// scene presets
|
||||||
|
loadScenes,
|
||||||
|
expandAllSceneSections,
|
||||||
|
collapseAllSceneSections,
|
||||||
|
openScenePresetCapture,
|
||||||
|
editScenePreset,
|
||||||
|
saveScenePreset,
|
||||||
|
closeScenePresetEditor,
|
||||||
|
activateScenePreset,
|
||||||
|
recaptureScenePreset,
|
||||||
|
deleteScenePreset,
|
||||||
|
|
||||||
// device-discovery
|
// device-discovery
|
||||||
onDeviceTypeChanged,
|
onDeviceTypeChanged,
|
||||||
updateBaudFpsHint,
|
updateBaudFpsHint,
|
||||||
@@ -422,9 +439,9 @@ document.addEventListener('keydown', (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab shortcuts: Ctrl+1..4 (skip when typing in inputs)
|
// Tab shortcuts: Ctrl+1..5 (skip when typing in inputs)
|
||||||
if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) {
|
if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) {
|
||||||
const tabMap = { '1': 'dashboard', '2': 'profiles', '3': 'targets', '4': 'streams' };
|
const tabMap = { '1': 'dashboard', '2': 'profiles', '3': 'targets', '4': 'streams', '5': 'scenes' };
|
||||||
const tab = tabMap[e.key];
|
const tab = tabMap[e.key];
|
||||||
if (tab) {
|
if (tab) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ export function isMockDevice(type) {
|
|||||||
return type === 'mock';
|
return type === 'mock';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMqttDevice(type) {
|
||||||
|
return type === 'mqtt';
|
||||||
|
}
|
||||||
|
|
||||||
export function handle401Error() {
|
export function handle401Error() {
|
||||||
if (!apiKey) return; // Already handled or no session
|
if (!apiKey) return; // Already handled or no session
|
||||||
localStorage.removeItem('wled_api_key');
|
localStorage.removeItem('wled_api_key');
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { t } from './i18n.js';
|
|||||||
import { navigateToCard } from './navigation.js';
|
import { navigateToCard } from './navigation.js';
|
||||||
import {
|
import {
|
||||||
getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon,
|
getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon,
|
||||||
ICON_DEVICE, ICON_TARGET, ICON_PROFILE, ICON_VALUE_SOURCE,
|
ICON_DEVICE, ICON_TARGET, ICON_PROFILE, ICON_VALUE_SOURCE, ICON_SCENE,
|
||||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE,
|
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE,
|
||||||
} from './icons.js';
|
} from './icons.js';
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ function _mapEntities(data, mapFn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _buildItems(results) {
|
function _buildItems(results) {
|
||||||
const [devices, targets, css, profiles, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams] = results;
|
const [devices, targets, css, profiles, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets] = results;
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
_mapEntities(devices, d => items.push({
|
_mapEntities(devices, d => items.push({
|
||||||
@@ -99,6 +99,11 @@ function _buildItems(results) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_mapEntities(scenePresets, sp => items.push({
|
||||||
|
name: sp.name, detail: sp.description || '', group: 'scenes', icon: ICON_SCENE,
|
||||||
|
nav: ['scenes', null, 'scenes', 'data-scene-id', sp.id],
|
||||||
|
}));
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +119,7 @@ const _responseKeys = [
|
|||||||
['/audio-sources', 'sources'],
|
['/audio-sources', 'sources'],
|
||||||
['/value-sources', 'sources'],
|
['/value-sources', 'sources'],
|
||||||
['/picture-sources', 'streams'],
|
['/picture-sources', 'streams'],
|
||||||
|
['/scene-presets', 'presets'],
|
||||||
];
|
];
|
||||||
|
|
||||||
async function _fetchAllEntities() {
|
async function _fetchAllEntities() {
|
||||||
@@ -132,7 +138,7 @@ async function _fetchAllEntities() {
|
|||||||
const _groupOrder = [
|
const _groupOrder = [
|
||||||
'devices', 'targets', 'kc_targets', 'css', 'profiles',
|
'devices', 'targets', 'kc_targets', 'css', 'profiles',
|
||||||
'streams', 'capture_templates', 'pp_templates', 'pattern_templates',
|
'streams', 'capture_templates', 'pp_templates', 'pattern_templates',
|
||||||
'audio', 'value',
|
'audio', 'value', 'scenes',
|
||||||
];
|
];
|
||||||
|
|
||||||
const _groupRank = new Map(_groupOrder.map((g, i) => [g, i]));
|
const _groupRank = new Map(_groupOrder.map((g, i) => [g, i]));
|
||||||
|
|||||||
@@ -144,3 +144,5 @@ export const ICON_ROTATE_CW = _svg(P.rotateCw);
|
|||||||
export const ICON_ROTATE_CCW = _svg(P.rotateCcw);
|
export const ICON_ROTATE_CCW = _svg(P.rotateCcw);
|
||||||
export const ICON_DOWNLOAD = _svg(P.download);
|
export const ICON_DOWNLOAD = _svg(P.download);
|
||||||
export const ICON_UNDO = _svg(P.undo2);
|
export const ICON_UNDO = _svg(P.undo2);
|
||||||
|
export const ICON_SCENE = _svg(P.sparkles);
|
||||||
|
export const ICON_CAPTURE = _svg(P.camera);
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ function _triggerTabLoad(tab) {
|
|||||||
else if (tab === 'profiles' && typeof window.loadProfiles === 'function') window.loadProfiles();
|
else if (tab === 'profiles' && typeof window.loadProfiles === 'function') window.loadProfiles();
|
||||||
else if (tab === 'streams' && typeof window.loadPictureSources === 'function') window.loadPictureSources();
|
else if (tab === 'streams' && typeof window.loadPictureSources === 'function') window.loadPictureSources();
|
||||||
else if (tab === 'targets' && typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
else if (tab === 'targets' && typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||||
|
else if (tab === 'scenes' && typeof window.loadScenes === 'function') window.loadScenes();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _showDimOverlay(duration) {
|
function _showDimOverlay(duration) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ICON_TARGET, ICON_PROFILE, ICON_CLOCK, ICON_WARNING, ICON_OK,
|
ICON_TARGET, ICON_PROFILE, ICON_CLOCK, ICON_WARNING, ICON_OK,
|
||||||
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP,
|
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP,
|
||||||
} from '../core/icons.js';
|
} from '../core/icons.js';
|
||||||
|
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js';
|
||||||
|
|
||||||
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
|
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
|
||||||
const MAX_FPS_SAMPLES = 120;
|
const MAX_FPS_SAMPLES = 120;
|
||||||
@@ -373,13 +374,14 @@ export async function loadDashboard(forceFullRender = false) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Fire all requests in a single batch to avoid sequential RTTs
|
// Fire all requests in a single batch to avoid sequential RTTs
|
||||||
const [targetsResp, profilesResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp] = await Promise.all([
|
const [targetsResp, profilesResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp, scenePresets] = await Promise.all([
|
||||||
fetchWithAuth('/picture-targets'),
|
fetchWithAuth('/picture-targets'),
|
||||||
fetchWithAuth('/profiles').catch(() => null),
|
fetchWithAuth('/profiles').catch(() => null),
|
||||||
fetchWithAuth('/devices').catch(() => null),
|
fetchWithAuth('/devices').catch(() => null),
|
||||||
fetchWithAuth('/color-strip-sources').catch(() => null),
|
fetchWithAuth('/color-strip-sources').catch(() => null),
|
||||||
fetchWithAuth('/picture-targets/batch/states').catch(() => null),
|
fetchWithAuth('/picture-targets/batch/states').catch(() => null),
|
||||||
fetchWithAuth('/picture-targets/batch/metrics').catch(() => null),
|
fetchWithAuth('/picture-targets/batch/metrics').catch(() => null),
|
||||||
|
loadScenePresets(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const targetsData = await targetsResp.json();
|
const targetsData = await targetsResp.json();
|
||||||
@@ -401,7 +403,7 @@ export async function loadDashboard(forceFullRender = false) {
|
|||||||
let runningIds = [];
|
let runningIds = [];
|
||||||
let newAutoStartIds = '';
|
let newAutoStartIds = '';
|
||||||
|
|
||||||
if (targets.length === 0 && profiles.length === 0) {
|
if (targets.length === 0 && profiles.length === 0 && scenePresets.length === 0) {
|
||||||
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
||||||
} else {
|
} else {
|
||||||
const enriched = targets.map(target => ({
|
const enriched = targets.map(target => ({
|
||||||
@@ -496,6 +498,17 @@ export async function loadDashboard(forceFullRender = false) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scene Presets section
|
||||||
|
if (scenePresets.length > 0) {
|
||||||
|
const sceneSec = renderScenePresetsSection(scenePresets);
|
||||||
|
if (sceneSec) {
|
||||||
|
dynamicHtml += `<div class="dashboard-section">
|
||||||
|
${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)}
|
||||||
|
${_sectionContent('scenes', sceneSec.content)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (targets.length > 0) {
|
if (targets.length > 0) {
|
||||||
let targetsInner = '';
|
let targetsInner = '';
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
_discoveryScanRunning, set_discoveryScanRunning,
|
_discoveryScanRunning, set_discoveryScanRunning,
|
||||||
_discoveryCache, set_discoveryCache,
|
_discoveryCache, set_discoveryCache,
|
||||||
} from '../core/state.js';
|
} from '../core/state.js';
|
||||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, escapeHtml } from '../core/api.js';
|
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, escapeHtml } from '../core/api.js';
|
||||||
import { t } from '../core/i18n.js';
|
import { t } from '../core/i18n.js';
|
||||||
import { showToast } from '../core/ui.js';
|
import { showToast } from '../core/ui.js';
|
||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
@@ -43,7 +43,29 @@ export function onDeviceTypeChanged() {
|
|||||||
const ledTypeGroup = document.getElementById('device-led-type-group');
|
const ledTypeGroup = document.getElementById('device-led-type-group');
|
||||||
const sendLatencyGroup = document.getElementById('device-send-latency-group');
|
const sendLatencyGroup = document.getElementById('device-send-latency-group');
|
||||||
|
|
||||||
if (isMockDevice(deviceType)) {
|
// URL label / hint / placeholder — adapt per device type
|
||||||
|
const urlLabel = document.getElementById('device-url-label');
|
||||||
|
const urlHint = document.getElementById('device-url-hint');
|
||||||
|
|
||||||
|
const scanBtn = document.getElementById('scan-network-btn');
|
||||||
|
|
||||||
|
if (isMqttDevice(deviceType)) {
|
||||||
|
// MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery
|
||||||
|
urlGroup.style.display = '';
|
||||||
|
urlInput.setAttribute('required', '');
|
||||||
|
serialGroup.style.display = 'none';
|
||||||
|
serialSelect.removeAttribute('required');
|
||||||
|
ledCountGroup.style.display = '';
|
||||||
|
baudRateGroup.style.display = 'none';
|
||||||
|
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
||||||
|
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
||||||
|
if (discoverySection) discoverySection.style.display = 'none';
|
||||||
|
if (scanBtn) scanBtn.style.display = 'none';
|
||||||
|
// Relabel URL field as "Topic"
|
||||||
|
if (urlLabel) urlLabel.textContent = t('device.mqtt_topic');
|
||||||
|
if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint');
|
||||||
|
urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room';
|
||||||
|
} else if (isMockDevice(deviceType)) {
|
||||||
urlGroup.style.display = 'none';
|
urlGroup.style.display = 'none';
|
||||||
urlInput.removeAttribute('required');
|
urlInput.removeAttribute('required');
|
||||||
serialGroup.style.display = 'none';
|
serialGroup.style.display = 'none';
|
||||||
@@ -53,6 +75,7 @@ export function onDeviceTypeChanged() {
|
|||||||
if (ledTypeGroup) ledTypeGroup.style.display = '';
|
if (ledTypeGroup) ledTypeGroup.style.display = '';
|
||||||
if (sendLatencyGroup) sendLatencyGroup.style.display = '';
|
if (sendLatencyGroup) sendLatencyGroup.style.display = '';
|
||||||
if (discoverySection) discoverySection.style.display = 'none';
|
if (discoverySection) discoverySection.style.display = 'none';
|
||||||
|
if (scanBtn) scanBtn.style.display = 'none';
|
||||||
} else if (isSerialDevice(deviceType)) {
|
} else if (isSerialDevice(deviceType)) {
|
||||||
urlGroup.style.display = 'none';
|
urlGroup.style.display = 'none';
|
||||||
urlInput.removeAttribute('required');
|
urlInput.removeAttribute('required');
|
||||||
@@ -62,6 +85,7 @@ export function onDeviceTypeChanged() {
|
|||||||
baudRateGroup.style.display = '';
|
baudRateGroup.style.display = '';
|
||||||
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
||||||
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
||||||
|
if (scanBtn) scanBtn.style.display = 'none';
|
||||||
// Hide discovery list — serial port dropdown replaces it
|
// Hide discovery list — serial port dropdown replaces it
|
||||||
if (discoverySection) discoverySection.style.display = 'none';
|
if (discoverySection) discoverySection.style.display = 'none';
|
||||||
// Populate from cache or show placeholder (lazy-load on focus)
|
// Populate from cache or show placeholder (lazy-load on focus)
|
||||||
@@ -85,6 +109,11 @@ export function onDeviceTypeChanged() {
|
|||||||
baudRateGroup.style.display = 'none';
|
baudRateGroup.style.display = 'none';
|
||||||
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
||||||
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
||||||
|
if (scanBtn) scanBtn.style.display = '';
|
||||||
|
// Restore default URL label/hint/placeholder
|
||||||
|
if (urlLabel) urlLabel.textContent = t('device.url');
|
||||||
|
if (urlHint) urlHint.textContent = t('device.url.hint');
|
||||||
|
urlInput.placeholder = t('device.url.placeholder') || 'http://192.168.1.100';
|
||||||
// Show cached results or trigger scan for WLED
|
// Show cached results or trigger scan for WLED
|
||||||
if (deviceType in _discoveryCache) {
|
if (deviceType in _discoveryCache) {
|
||||||
_renderDiscoveryList();
|
_renderDiscoveryList();
|
||||||
@@ -316,6 +345,11 @@ export async function handleAddDevice(event) {
|
|||||||
url = document.getElementById('device-url').value.trim();
|
url = document.getElementById('device-url').value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MQTT: ensure mqtt:// prefix
|
||||||
|
if (isMqttDevice(deviceType) && url && !url.startsWith('mqtt://')) {
|
||||||
|
url = 'mqtt://' + url;
|
||||||
|
}
|
||||||
|
|
||||||
if (!name || (!isMockDevice(deviceType) && !url)) {
|
if (!name || (!isMockDevice(deviceType) && !url)) {
|
||||||
error.textContent = t('device_discovery.error.fill_all_fields');
|
error.textContent = t('device_discovery.error.fill_all_fields');
|
||||||
error.style.display = 'block';
|
error.style.display = 'block';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import {
|
import {
|
||||||
_deviceBrightnessCache, updateDeviceBrightness,
|
_deviceBrightnessCache, updateDeviceBrightness,
|
||||||
} from '../core/state.js';
|
} from '../core/state.js';
|
||||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice } from '../core/api.js';
|
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice } from '../core/api.js';
|
||||||
import { t } from '../core/i18n.js';
|
import { t } from '../core/i18n.js';
|
||||||
import { showToast, showConfirm } from '../core/ui.js';
|
import { showToast, showConfirm } from '../core/ui.js';
|
||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
@@ -174,22 +174,36 @@ export async function showSettings(deviceId) {
|
|||||||
document.getElementById('settings-health-interval').value = device.state_check_interval ?? 30;
|
document.getElementById('settings-health-interval').value = device.state_check_interval ?? 30;
|
||||||
|
|
||||||
const isMock = isMockDevice(device.device_type);
|
const isMock = isMockDevice(device.device_type);
|
||||||
|
const isMqtt = isMqttDevice(device.device_type);
|
||||||
const urlGroup = document.getElementById('settings-url-group');
|
const urlGroup = document.getElementById('settings-url-group');
|
||||||
const serialGroup = document.getElementById('settings-serial-port-group');
|
const serialGroup = document.getElementById('settings-serial-port-group');
|
||||||
|
const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]');
|
||||||
|
const urlHint = urlGroup.querySelector('.input-hint');
|
||||||
|
const urlInput = document.getElementById('settings-device-url');
|
||||||
if (isMock) {
|
if (isMock) {
|
||||||
urlGroup.style.display = 'none';
|
urlGroup.style.display = 'none';
|
||||||
document.getElementById('settings-device-url').removeAttribute('required');
|
urlInput.removeAttribute('required');
|
||||||
serialGroup.style.display = 'none';
|
serialGroup.style.display = 'none';
|
||||||
} else if (isAdalight) {
|
} else if (isAdalight) {
|
||||||
urlGroup.style.display = 'none';
|
urlGroup.style.display = 'none';
|
||||||
document.getElementById('settings-device-url').removeAttribute('required');
|
urlInput.removeAttribute('required');
|
||||||
serialGroup.style.display = '';
|
serialGroup.style.display = '';
|
||||||
_populateSettingsSerialPorts(device.url);
|
_populateSettingsSerialPorts(device.url);
|
||||||
} else {
|
} else {
|
||||||
urlGroup.style.display = '';
|
urlGroup.style.display = '';
|
||||||
document.getElementById('settings-device-url').setAttribute('required', '');
|
urlInput.setAttribute('required', '');
|
||||||
document.getElementById('settings-device-url').value = device.url;
|
urlInput.value = device.url;
|
||||||
serialGroup.style.display = 'none';
|
serialGroup.style.display = 'none';
|
||||||
|
// Relabel for MQTT
|
||||||
|
if (isMqtt) {
|
||||||
|
if (urlLabel) urlLabel.textContent = t('device.mqtt_topic');
|
||||||
|
if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint');
|
||||||
|
urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room';
|
||||||
|
} else {
|
||||||
|
if (urlLabel) urlLabel.textContent = t('device.url');
|
||||||
|
if (urlHint) urlHint.textContent = t('settings.url.hint');
|
||||||
|
urlInput.placeholder = t('device.url.placeholder') || 'http://192.168.1.100';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ledCountGroup = document.getElementById('settings-led-count-group');
|
const ledCountGroup = document.getElementById('settings-led-count-group');
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js';
|
|||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
import { CardSection } from '../core/card-sections.js';
|
import { CardSection } from '../core/card-sections.js';
|
||||||
import { updateTabBadge } from './tabs.js';
|
import { updateTabBadge } from './tabs.js';
|
||||||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_TARGET, ICON_PROFILE, ICON_HELP, ICON_OK } from '../core/icons.js';
|
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_TARGET, ICON_PROFILE, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO } from '../core/icons.js';
|
||||||
|
|
||||||
class ProfileEditorModal extends Modal {
|
class ProfileEditorModal extends Modal {
|
||||||
constructor() { super('profile-editor-modal'); }
|
constructor() { super('profile-editor-modal'); }
|
||||||
@@ -116,6 +116,20 @@ function createProfileCard(profile, runningTargetIds = new Set()) {
|
|||||||
const matchLabel = t('profiles.condition.application.match_type.' + (c.match_type || 'running'));
|
const matchLabel = t('profiles.condition.application.match_type.' + (c.match_type || 'running'));
|
||||||
return `<span class="stream-card-prop stream-card-prop-full">${t('profiles.condition.application')}: ${apps} (${matchLabel})</span>`;
|
return `<span class="stream-card-prop stream-card-prop-full">${t('profiles.condition.application')}: ${apps} (${matchLabel})</span>`;
|
||||||
}
|
}
|
||||||
|
if (c.condition_type === 'time_of_day') {
|
||||||
|
return `<span class="stream-card-prop">${ICON_CLOCK} ${c.start_time || '00:00'} – ${c.end_time || '23:59'}</span>`;
|
||||||
|
}
|
||||||
|
if (c.condition_type === 'system_idle') {
|
||||||
|
const mode = c.when_idle !== false ? t('profiles.condition.system_idle.when_idle') : t('profiles.condition.system_idle.when_active');
|
||||||
|
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`;
|
||||||
|
}
|
||||||
|
if (c.condition_type === 'display_state') {
|
||||||
|
const stateLabel = t('profiles.condition.display_state.' + (c.state || 'on'));
|
||||||
|
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('profiles.condition.display_state')}: ${stateLabel}</span>`;
|
||||||
|
}
|
||||||
|
if (c.condition_type === 'mqtt') {
|
||||||
|
return `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('profiles.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`;
|
||||||
|
}
|
||||||
return `<span class="stream-card-prop">${c.condition_type}</span>`;
|
return `<span class="stream-card-prop">${c.condition_type}</span>`;
|
||||||
});
|
});
|
||||||
const logicLabel = profile.condition_logic === 'and' ? t('profiles.logic.and') : t('profiles.logic.or');
|
const logicLabel = profile.condition_logic === 'and' ? t('profiles.logic.and') : t('profiles.logic.or');
|
||||||
@@ -259,6 +273,10 @@ function addProfileConditionRow(condition) {
|
|||||||
<select class="condition-type-select">
|
<select class="condition-type-select">
|
||||||
<option value="always" ${condType === 'always' ? 'selected' : ''}>${t('profiles.condition.always')}</option>
|
<option value="always" ${condType === 'always' ? 'selected' : ''}>${t('profiles.condition.always')}</option>
|
||||||
<option value="application" ${condType === 'application' ? 'selected' : ''}>${t('profiles.condition.application')}</option>
|
<option value="application" ${condType === 'application' ? 'selected' : ''}>${t('profiles.condition.application')}</option>
|
||||||
|
<option value="time_of_day" ${condType === 'time_of_day' ? 'selected' : ''}>${t('profiles.condition.time_of_day')}</option>
|
||||||
|
<option value="system_idle" ${condType === 'system_idle' ? 'selected' : ''}>${t('profiles.condition.system_idle')}</option>
|
||||||
|
<option value="display_state" ${condType === 'display_state' ? 'selected' : ''}>${t('profiles.condition.display_state')}</option>
|
||||||
|
<option value="mqtt" ${condType === 'mqtt' ? 'selected' : ''}>${t('profiles.condition.mqtt')}</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="btn-remove-condition" onclick="this.closest('.profile-condition-row').remove()" title="Remove">✕</button>
|
<button type="button" class="btn-remove-condition" onclick="this.closest('.profile-condition-row').remove()" title="Remove">✕</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,6 +291,81 @@ function addProfileConditionRow(condition) {
|
|||||||
container.innerHTML = `<small class="condition-always-desc">${t('profiles.condition.always.hint')}</small>`;
|
container.innerHTML = `<small class="condition-always-desc">${t('profiles.condition.always.hint')}</small>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (type === 'time_of_day') {
|
||||||
|
const startTime = data.start_time || '00:00';
|
||||||
|
const endTime = data.end_time || '23:59';
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="condition-fields">
|
||||||
|
<div class="condition-field">
|
||||||
|
<label>${t('profiles.condition.time_of_day.start_time')}</label>
|
||||||
|
<input type="time" class="condition-start-time" value="${startTime}">
|
||||||
|
</div>
|
||||||
|
<div class="condition-field">
|
||||||
|
<label>${t('profiles.condition.time_of_day.end_time')}</label>
|
||||||
|
<input type="time" class="condition-end-time" value="${endTime}">
|
||||||
|
</div>
|
||||||
|
<small class="condition-always-desc">${t('profiles.condition.time_of_day.overnight_hint')}</small>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'system_idle') {
|
||||||
|
const idleMinutes = data.idle_minutes ?? 5;
|
||||||
|
const whenIdle = data.when_idle ?? true;
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="condition-fields">
|
||||||
|
<div class="condition-field">
|
||||||
|
<label>${t('profiles.condition.system_idle.idle_minutes')}</label>
|
||||||
|
<input type="number" class="condition-idle-minutes" min="1" max="999" value="${idleMinutes}">
|
||||||
|
</div>
|
||||||
|
<div class="condition-field">
|
||||||
|
<label>${t('profiles.condition.system_idle.mode')}</label>
|
||||||
|
<select class="condition-when-idle">
|
||||||
|
<option value="true" ${whenIdle ? 'selected' : ''}>${t('profiles.condition.system_idle.when_idle')}</option>
|
||||||
|
<option value="false" ${!whenIdle ? 'selected' : ''}>${t('profiles.condition.system_idle.when_active')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'display_state') {
|
||||||
|
const dState = data.state || 'on';
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="condition-fields">
|
||||||
|
<div class="condition-field">
|
||||||
|
<label>${t('profiles.condition.display_state.state')}</label>
|
||||||
|
<select class="condition-display-state">
|
||||||
|
<option value="on" ${dState === 'on' ? 'selected' : ''}>${t('profiles.condition.display_state.on')}</option>
|
||||||
|
<option value="off" ${dState === 'off' ? 'selected' : ''}>${t('profiles.condition.display_state.off')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'mqtt') {
|
||||||
|
const topic = data.topic || '';
|
||||||
|
const payload = data.payload || '';
|
||||||
|
const matchMode = data.match_mode || 'exact';
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="condition-fields">
|
||||||
|
<div class="condition-field">
|
||||||
|
<label>${t('profiles.condition.mqtt.topic')}</label>
|
||||||
|
<input type="text" class="condition-mqtt-topic" value="${escapeHtml(topic)}" placeholder="home/status/power">
|
||||||
|
</div>
|
||||||
|
<div class="condition-field">
|
||||||
|
<label>${t('profiles.condition.mqtt.payload')}</label>
|
||||||
|
<input type="text" class="condition-mqtt-payload" value="${escapeHtml(payload)}" placeholder="ON">
|
||||||
|
</div>
|
||||||
|
<div class="condition-field">
|
||||||
|
<label>${t('profiles.condition.mqtt.match_mode')}</label>
|
||||||
|
<select class="condition-mqtt-match-mode">
|
||||||
|
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('profiles.condition.mqtt.match_mode.exact')}</option>
|
||||||
|
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('profiles.condition.mqtt.match_mode.contains')}</option>
|
||||||
|
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('profiles.condition.mqtt.match_mode.regex')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const appsValue = (data.apps || []).join('\n');
|
const appsValue = (data.apps || []).join('\n');
|
||||||
const matchType = data.match_type || 'running';
|
const matchType = data.match_type || 'running';
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
@@ -308,7 +401,7 @@ function addProfileConditionRow(condition) {
|
|||||||
|
|
||||||
renderFields(condType, condition);
|
renderFields(condType, condition);
|
||||||
typeSelect.addEventListener('change', () => {
|
typeSelect.addEventListener('change', () => {
|
||||||
renderFields(typeSelect.value, { apps: [], match_type: 'running' });
|
renderFields(typeSelect.value, {});
|
||||||
});
|
});
|
||||||
|
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
@@ -382,6 +475,30 @@ function getProfileEditorConditions() {
|
|||||||
const condType = typeSelect ? typeSelect.value : 'application';
|
const condType = typeSelect ? typeSelect.value : 'application';
|
||||||
if (condType === 'always') {
|
if (condType === 'always') {
|
||||||
conditions.push({ condition_type: 'always' });
|
conditions.push({ condition_type: 'always' });
|
||||||
|
} else if (condType === 'time_of_day') {
|
||||||
|
conditions.push({
|
||||||
|
condition_type: 'time_of_day',
|
||||||
|
start_time: row.querySelector('.condition-start-time').value || '00:00',
|
||||||
|
end_time: row.querySelector('.condition-end-time').value || '23:59',
|
||||||
|
});
|
||||||
|
} else if (condType === 'system_idle') {
|
||||||
|
conditions.push({
|
||||||
|
condition_type: 'system_idle',
|
||||||
|
idle_minutes: parseInt(row.querySelector('.condition-idle-minutes').value, 10) || 5,
|
||||||
|
when_idle: row.querySelector('.condition-when-idle').value === 'true',
|
||||||
|
});
|
||||||
|
} else if (condType === 'display_state') {
|
||||||
|
conditions.push({
|
||||||
|
condition_type: 'display_state',
|
||||||
|
state: row.querySelector('.condition-display-state').value || 'on',
|
||||||
|
});
|
||||||
|
} else if (condType === 'mqtt') {
|
||||||
|
conditions.push({
|
||||||
|
condition_type: 'mqtt',
|
||||||
|
topic: row.querySelector('.condition-mqtt-topic').value.trim(),
|
||||||
|
payload: row.querySelector('.condition-mqtt-payload').value,
|
||||||
|
match_mode: row.querySelector('.condition-mqtt-match-mode').value || 'exact',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const matchType = row.querySelector('.condition-match-type').value;
|
const matchType = row.querySelector('.condition-match-type').value;
|
||||||
const appsText = row.querySelector('.condition-apps').value.trim();
|
const appsText = row.querySelector('.condition-apps').value.trim();
|
||||||
|
|||||||
336
server/src/wled_controller/static/js/features/scene-presets.js
Normal file
336
server/src/wled_controller/static/js/features/scene-presets.js
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
/**
|
||||||
|
* Scene Presets — capture, activate, edit, delete system state snapshots.
|
||||||
|
* Renders as a dedicated tab and also provides dashboard section rendering.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiKey } from '../core/state.js';
|
||||||
|
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||||
|
import { t } from '../core/i18n.js';
|
||||||
|
import { showToast, showConfirm } from '../core/ui.js';
|
||||||
|
import { Modal } from '../core/modal.js';
|
||||||
|
import { CardSection } from '../core/card-sections.js';
|
||||||
|
import { updateTabBadge } from './tabs.js';
|
||||||
|
import {
|
||||||
|
ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_SETTINGS,
|
||||||
|
} from '../core/icons.js';
|
||||||
|
|
||||||
|
let _presetsCache = [];
|
||||||
|
let _editingId = null;
|
||||||
|
let _scenesLoading = false;
|
||||||
|
|
||||||
|
class ScenePresetEditorModal extends Modal {
|
||||||
|
constructor() { super('scene-preset-editor-modal'); }
|
||||||
|
snapshotValues() {
|
||||||
|
return {
|
||||||
|
name: document.getElementById('scene-preset-editor-name').value,
|
||||||
|
description: document.getElementById('scene-preset-editor-description').value,
|
||||||
|
color: document.getElementById('scene-preset-editor-color').value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const scenePresetModal = new ScenePresetEditorModal();
|
||||||
|
|
||||||
|
const csScenes = new CardSection('scenes', {
|
||||||
|
titleKey: 'scenes.title',
|
||||||
|
gridClass: 'devices-grid',
|
||||||
|
addCardOnclick: "openScenePresetCapture()",
|
||||||
|
keyAttr: 'data-scene-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-render scenes when language changes (only if tab is active)
|
||||||
|
document.addEventListener('languageChanged', () => {
|
||||||
|
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'scenes') loadScenes();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Tab rendering =====
|
||||||
|
|
||||||
|
export async function loadScenes() {
|
||||||
|
if (_scenesLoading) return;
|
||||||
|
_scenesLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetchWithAuth('/scene-presets');
|
||||||
|
if (!resp.ok) { _scenesLoading = false; return; }
|
||||||
|
const data = await resp.json();
|
||||||
|
_presetsCache = data.presets || [];
|
||||||
|
} catch {
|
||||||
|
_scenesLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById('scenes-content');
|
||||||
|
const items = csScenes.applySortOrder(_presetsCache.map(p => ({ key: p.id, html: _createSceneCard(p) })));
|
||||||
|
|
||||||
|
updateTabBadge('scenes', _presetsCache.length);
|
||||||
|
|
||||||
|
if (csScenes.isMounted()) {
|
||||||
|
csScenes.reconcile(items);
|
||||||
|
} else {
|
||||||
|
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllSceneSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllSceneSections()" title="${t('section.collapse_all')}">⊟</button></span></div>`;
|
||||||
|
container.innerHTML = toolbar + csScenes.render(items);
|
||||||
|
csScenes.bind();
|
||||||
|
}
|
||||||
|
|
||||||
|
_scenesLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandAllSceneSections() {
|
||||||
|
CardSection.expandAll([csScenes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collapseAllSceneSections() {
|
||||||
|
CardSection.collapseAll([csScenes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _createSceneCard(preset) {
|
||||||
|
const targetCount = (preset.targets || []).length;
|
||||||
|
const deviceCount = (preset.devices || []).length;
|
||||||
|
const profileCount = (preset.profiles || []).length;
|
||||||
|
const colorStyle = `border-left: 3px solid ${escapeHtml(preset.color || '#4fc3f7')}`;
|
||||||
|
|
||||||
|
const meta = [
|
||||||
|
targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null,
|
||||||
|
deviceCount > 0 ? `${ICON_SETTINGS} ${deviceCount} ${t('scenes.devices_count')}` : null,
|
||||||
|
profileCount > 0 ? `${profileCount} ${t('scenes.profiles_count')}` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : '';
|
||||||
|
|
||||||
|
return `<div class="card" data-scene-id="${preset.id}" style="${colorStyle}">
|
||||||
|
<div class="card-top-actions">
|
||||||
|
<button class="card-remove-btn" onclick="deleteScenePreset('${preset.id}', '${escapeHtml(preset.name)}')" title="${t('scenes.delete')}">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">${escapeHtml(preset.name)}</div>
|
||||||
|
</div>
|
||||||
|
${preset.description ? `<div class="card-subtitle"><span class="card-meta">${escapeHtml(preset.description)}</span></div>` : ''}
|
||||||
|
<div class="stream-card-props">
|
||||||
|
${meta.map(m => `<span class="stream-card-prop">${m}</span>`).join('')}
|
||||||
|
${updated ? `<span class="stream-card-prop">${updated}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="editScenePreset('${preset.id}')" title="${t('scenes.edit')}">${ICON_EDIT}</button>
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="recaptureScenePreset('${preset.id}')" title="${t('scenes.recapture')}">${ICON_REFRESH}</button>
|
||||||
|
<button class="btn btn-icon btn-success" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Dashboard section (compact cards) =====
|
||||||
|
|
||||||
|
export async function loadScenePresets() {
|
||||||
|
try {
|
||||||
|
const resp = await fetchWithAuth('/scene-presets');
|
||||||
|
if (!resp.ok) return [];
|
||||||
|
const data = await resp.json();
|
||||||
|
_presetsCache = data.presets || [];
|
||||||
|
return _presetsCache;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderScenePresetsSection(presets) {
|
||||||
|
if (!presets || presets.length === 0) return '';
|
||||||
|
|
||||||
|
const captureBtn = `<button class="btn btn-sm btn-primary" onclick="event.stopPropagation(); openScenePresetCapture()" title="${t('scenes.capture')}">${ICON_CAPTURE} ${t('scenes.capture')}</button>`;
|
||||||
|
const cards = presets.map(p => _renderDashboardPresetCard(p)).join('');
|
||||||
|
|
||||||
|
return { headerExtra: captureBtn, content: `<div class="dashboard-autostart-grid">${cards}</div>` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderDashboardPresetCard(preset) {
|
||||||
|
const borderStyle = `border-left: 3px solid ${escapeHtml(preset.color)}`;
|
||||||
|
const targetCount = (preset.targets || []).length;
|
||||||
|
const deviceCount = (preset.devices || []).length;
|
||||||
|
const profileCount = (preset.profiles || []).length;
|
||||||
|
|
||||||
|
const subtitle = [
|
||||||
|
targetCount > 0 ? `${targetCount} ${t('scenes.targets_count')}` : null,
|
||||||
|
deviceCount > 0 ? `${deviceCount} ${t('scenes.devices_count')}` : null,
|
||||||
|
profileCount > 0 ? `${profileCount} ${t('scenes.profiles_count')}` : null,
|
||||||
|
].filter(Boolean).join(' \u00b7 ');
|
||||||
|
|
||||||
|
return `<div class="dashboard-target dashboard-scene-preset" data-scene-id="${preset.id}" style="${borderStyle}">
|
||||||
|
<div class="dashboard-target-info" onclick="activateScenePreset('${preset.id}')">
|
||||||
|
<span class="dashboard-target-icon">${ICON_SCENE}</span>
|
||||||
|
<div>
|
||||||
|
<div class="dashboard-target-name">${escapeHtml(preset.name)}</div>
|
||||||
|
${preset.description ? `<div class="dashboard-target-subtitle">${escapeHtml(preset.description)}</div>` : ''}
|
||||||
|
<div class="dashboard-target-subtitle">${subtitle}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-target-actions">
|
||||||
|
<button class="dashboard-action-btn start" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Capture (create) =====
|
||||||
|
|
||||||
|
export function openScenePresetCapture() {
|
||||||
|
_editingId = null;
|
||||||
|
document.getElementById('scene-preset-editor-id').value = '';
|
||||||
|
document.getElementById('scene-preset-editor-name').value = '';
|
||||||
|
document.getElementById('scene-preset-editor-description').value = '';
|
||||||
|
document.getElementById('scene-preset-editor-color').value = '#4fc3f7';
|
||||||
|
document.getElementById('scene-preset-editor-error').style.display = 'none';
|
||||||
|
|
||||||
|
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
|
||||||
|
if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.add'); titleEl.textContent = t('scenes.add'); }
|
||||||
|
|
||||||
|
scenePresetModal.open();
|
||||||
|
scenePresetModal.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Edit metadata =====
|
||||||
|
|
||||||
|
export async function editScenePreset(presetId) {
|
||||||
|
const preset = _presetsCache.find(p => p.id === presetId);
|
||||||
|
if (!preset) return;
|
||||||
|
|
||||||
|
_editingId = presetId;
|
||||||
|
document.getElementById('scene-preset-editor-id').value = presetId;
|
||||||
|
document.getElementById('scene-preset-editor-name').value = preset.name;
|
||||||
|
document.getElementById('scene-preset-editor-description').value = preset.description || '';
|
||||||
|
document.getElementById('scene-preset-editor-color').value = preset.color || '#4fc3f7';
|
||||||
|
document.getElementById('scene-preset-editor-error').style.display = 'none';
|
||||||
|
|
||||||
|
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
|
||||||
|
if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.edit'); titleEl.textContent = t('scenes.edit'); }
|
||||||
|
|
||||||
|
scenePresetModal.open();
|
||||||
|
scenePresetModal.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Save (create or update) =====
|
||||||
|
|
||||||
|
export async function saveScenePreset() {
|
||||||
|
const name = document.getElementById('scene-preset-editor-name').value.trim();
|
||||||
|
const description = document.getElementById('scene-preset-editor-description').value.trim();
|
||||||
|
const color = document.getElementById('scene-preset-editor-color').value;
|
||||||
|
const errorEl = document.getElementById('scene-preset-editor-error');
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
errorEl.textContent = t('scenes.error.name_required');
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let resp;
|
||||||
|
if (_editingId) {
|
||||||
|
resp = await fetchWithAuth(`/scene-presets/${_editingId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ name, description, color }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resp = await fetchWithAuth('/scene-presets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, description, color }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
errorEl.textContent = err.detail || t('scenes.error.save_failed');
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scenePresetModal.forceClose();
|
||||||
|
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
|
||||||
|
_reloadScenesTab();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.isAuth) return;
|
||||||
|
errorEl.textContent = t('scenes.error.save_failed');
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeScenePresetEditor() {
|
||||||
|
await scenePresetModal.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Activate =====
|
||||||
|
|
||||||
|
export async function activateScenePreset(presetId) {
|
||||||
|
try {
|
||||||
|
const resp = await fetchWithAuth(`/scene-presets/${presetId}/activate`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
showToast(t('scenes.error.activate_failed'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await resp.json();
|
||||||
|
if (result.status === 'activated') {
|
||||||
|
showToast(t('scenes.activated'), 'success');
|
||||||
|
} else {
|
||||||
|
showToast(`${t('scenes.activated_partial')}: ${result.errors.length} ${t('scenes.errors')}`, 'warning');
|
||||||
|
}
|
||||||
|
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.isAuth) return;
|
||||||
|
showToast(t('scenes.error.activate_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Recapture =====
|
||||||
|
|
||||||
|
export async function recaptureScenePreset(presetId) {
|
||||||
|
const preset = _presetsCache.find(p => p.id === presetId);
|
||||||
|
const name = preset ? preset.name : presetId;
|
||||||
|
const confirmed = await showConfirm(t('scenes.recapture_confirm', { name }));
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetchWithAuth(`/scene-presets/${presetId}/recapture`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
showToast(t('scenes.recaptured'), 'success');
|
||||||
|
_reloadScenesTab();
|
||||||
|
} else {
|
||||||
|
showToast(t('scenes.error.recapture_failed'), 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.isAuth) return;
|
||||||
|
showToast(t('scenes.error.recapture_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Delete =====
|
||||||
|
|
||||||
|
export async function deleteScenePreset(presetId) {
|
||||||
|
const preset = _presetsCache.find(p => p.id === presetId);
|
||||||
|
const name = preset ? preset.name : presetId;
|
||||||
|
const confirmed = await showConfirm(t('scenes.delete_confirm', { name }));
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetchWithAuth(`/scene-presets/${presetId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
showToast(t('scenes.deleted'), 'success');
|
||||||
|
_reloadScenesTab();
|
||||||
|
} else {
|
||||||
|
showToast(t('scenes.error.delete_failed'), 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.isAuth) return;
|
||||||
|
showToast(t('scenes.error.delete_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Helpers =====
|
||||||
|
|
||||||
|
function _reloadScenesTab() {
|
||||||
|
// Reload the scenes tab if it's active
|
||||||
|
if ((localStorage.getItem('activeTab') || 'dashboard') === 'scenes') {
|
||||||
|
loadScenes();
|
||||||
|
}
|
||||||
|
// Also refresh dashboard (scene presets section)
|
||||||
|
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
|
||||||
|
}
|
||||||
@@ -19,8 +19,12 @@ function _setHash(tab, subTab) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _suppressHashUpdate = false;
|
let _suppressHashUpdate = false;
|
||||||
|
let _activeTab = null;
|
||||||
|
|
||||||
export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
|
export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
|
||||||
|
if (_activeTab === name) return;
|
||||||
|
_activeTab = name;
|
||||||
|
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
const isActive = btn.dataset.tab === name;
|
const isActive = btn.dataset.tab === name;
|
||||||
btn.classList.toggle('active', isActive);
|
btn.classList.toggle('active', isActive);
|
||||||
@@ -56,6 +60,8 @@ export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
|
|||||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||||
} else if (name === 'profiles') {
|
} else if (name === 'profiles') {
|
||||||
if (typeof window.loadProfiles === 'function') window.loadProfiles();
|
if (typeof window.loadProfiles === 'function') window.loadProfiles();
|
||||||
|
} else if (name === 'scenes') {
|
||||||
|
if (typeof window.loadScenes === 'function') window.loadScenes();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,9 @@
|
|||||||
"device.led_type.hint": "RGB (3 channels) or RGBW (4 channels with dedicated white)",
|
"device.led_type.hint": "RGB (3 channels) or RGBW (4 channels with dedicated white)",
|
||||||
"device.send_latency": "Send Latency (ms):",
|
"device.send_latency": "Send Latency (ms):",
|
||||||
"device.send_latency.hint": "Simulated network/serial delay per frame in milliseconds",
|
"device.send_latency.hint": "Simulated network/serial delay per frame in milliseconds",
|
||||||
|
"device.mqtt_topic": "MQTT Topic:",
|
||||||
|
"device.mqtt_topic.hint": "MQTT topic path for publishing pixel data (e.g. mqtt://ledgrab/device/name)",
|
||||||
|
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/living-room",
|
||||||
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
|
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
|
||||||
"device.name": "Device Name:",
|
"device.name": "Device Name:",
|
||||||
"device.name.placeholder": "Living Room TV",
|
"device.name.placeholder": "Living Room TV",
|
||||||
@@ -529,6 +532,7 @@
|
|||||||
"dashboard.stop_all": "Stop All",
|
"dashboard.stop_all": "Stop All",
|
||||||
"dashboard.failed": "Failed to load dashboard",
|
"dashboard.failed": "Failed to load dashboard",
|
||||||
"dashboard.section.profiles": "Profiles",
|
"dashboard.section.profiles": "Profiles",
|
||||||
|
"dashboard.section.scenes": "Scene Presets",
|
||||||
"dashboard.targets": "Targets",
|
"dashboard.targets": "Targets",
|
||||||
"dashboard.section.performance": "System Performance",
|
"dashboard.section.performance": "System Performance",
|
||||||
"dashboard.perf.cpu": "CPU",
|
"dashboard.perf.cpu": "CPU",
|
||||||
@@ -568,6 +572,27 @@
|
|||||||
"profiles.condition.application.match_type.topmost": "Topmost (foreground)",
|
"profiles.condition.application.match_type.topmost": "Topmost (foreground)",
|
||||||
"profiles.condition.application.match_type.topmost_fullscreen": "Topmost + Fullscreen",
|
"profiles.condition.application.match_type.topmost_fullscreen": "Topmost + Fullscreen",
|
||||||
"profiles.condition.application.match_type.fullscreen": "Fullscreen",
|
"profiles.condition.application.match_type.fullscreen": "Fullscreen",
|
||||||
|
"profiles.condition.time_of_day": "Time of Day",
|
||||||
|
"profiles.condition.time_of_day.start_time": "Start Time:",
|
||||||
|
"profiles.condition.time_of_day.end_time": "End Time:",
|
||||||
|
"profiles.condition.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:00–06:00), set start time after end time.",
|
||||||
|
"profiles.condition.system_idle": "System Idle",
|
||||||
|
"profiles.condition.system_idle.idle_minutes": "Idle Timeout (minutes):",
|
||||||
|
"profiles.condition.system_idle.mode": "Trigger Mode:",
|
||||||
|
"profiles.condition.system_idle.when_idle": "When idle",
|
||||||
|
"profiles.condition.system_idle.when_active": "When active",
|
||||||
|
"profiles.condition.display_state": "Display State",
|
||||||
|
"profiles.condition.display_state.state": "Monitor State:",
|
||||||
|
"profiles.condition.display_state.on": "On",
|
||||||
|
"profiles.condition.display_state.off": "Off (sleeping)",
|
||||||
|
"profiles.condition.mqtt": "MQTT",
|
||||||
|
"profiles.condition.mqtt.topic": "Topic:",
|
||||||
|
"profiles.condition.mqtt.payload": "Payload:",
|
||||||
|
"profiles.condition.mqtt.match_mode": "Match Mode:",
|
||||||
|
"profiles.condition.mqtt.match_mode.exact": "Exact",
|
||||||
|
"profiles.condition.mqtt.match_mode.contains": "Contains",
|
||||||
|
"profiles.condition.mqtt.match_mode.regex": "Regex",
|
||||||
|
"profiles.condition.mqtt.hint": "Activate when an MQTT topic receives a matching payload",
|
||||||
"profiles.targets": "Targets:",
|
"profiles.targets": "Targets:",
|
||||||
"profiles.targets.hint": "Targets to start when this profile activates",
|
"profiles.targets.hint": "Targets to start when this profile activates",
|
||||||
"profiles.targets.empty": "No targets available",
|
"profiles.targets.empty": "No targets available",
|
||||||
@@ -586,6 +611,36 @@
|
|||||||
"profiles.error.name_required": "Name is required",
|
"profiles.error.name_required": "Name is required",
|
||||||
"profiles.toggle_all.start": "Start all targets",
|
"profiles.toggle_all.start": "Start all targets",
|
||||||
"profiles.toggle_all.stop": "Stop all targets",
|
"profiles.toggle_all.stop": "Stop all targets",
|
||||||
|
"scenes.title": "Scenes",
|
||||||
|
"scenes.add": "Capture Scene",
|
||||||
|
"scenes.edit": "Edit Scene",
|
||||||
|
"scenes.name": "Name:",
|
||||||
|
"scenes.name.hint": "A descriptive name for this scene preset",
|
||||||
|
"scenes.description": "Description:",
|
||||||
|
"scenes.description.hint": "Optional description of what this scene does",
|
||||||
|
"scenes.color": "Card Color:",
|
||||||
|
"scenes.color.hint": "Accent color for the scene card on the dashboard",
|
||||||
|
"scenes.capture": "Capture",
|
||||||
|
"scenes.activate": "Activate scene",
|
||||||
|
"scenes.recapture": "Recapture current state",
|
||||||
|
"scenes.delete": "Delete scene",
|
||||||
|
"scenes.targets_count": "targets",
|
||||||
|
"scenes.devices_count": "devices",
|
||||||
|
"scenes.profiles_count": "profiles",
|
||||||
|
"scenes.captured": "Scene captured",
|
||||||
|
"scenes.updated": "Scene updated",
|
||||||
|
"scenes.activated": "Scene activated",
|
||||||
|
"scenes.activated_partial": "Scene partially activated",
|
||||||
|
"scenes.errors": "errors",
|
||||||
|
"scenes.recaptured": "Scene recaptured",
|
||||||
|
"scenes.deleted": "Scene deleted",
|
||||||
|
"scenes.recapture_confirm": "Recapture current state into \"{name}\"?",
|
||||||
|
"scenes.delete_confirm": "Delete scene \"{name}\"?",
|
||||||
|
"scenes.error.name_required": "Name is required",
|
||||||
|
"scenes.error.save_failed": "Failed to save scene",
|
||||||
|
"scenes.error.activate_failed": "Failed to activate scene",
|
||||||
|
"scenes.error.recapture_failed": "Failed to recapture scene",
|
||||||
|
"scenes.error.delete_failed": "Failed to delete scene",
|
||||||
"autostart.title": "Auto-start Targets",
|
"autostart.title": "Auto-start Targets",
|
||||||
"autostart.toggle.enabled": "Auto-start enabled",
|
"autostart.toggle.enabled": "Auto-start enabled",
|
||||||
"autostart.toggle.disabled": "Auto-start disabled",
|
"autostart.toggle.disabled": "Auto-start disabled",
|
||||||
@@ -961,6 +1016,7 @@
|
|||||||
"search.group.pattern_templates": "Pattern Templates",
|
"search.group.pattern_templates": "Pattern Templates",
|
||||||
"search.group.audio": "Audio Sources",
|
"search.group.audio": "Audio Sources",
|
||||||
"search.group.value": "Value Sources",
|
"search.group.value": "Value Sources",
|
||||||
|
"search.group.scenes": "Scene Presets",
|
||||||
"settings.backup.label": "Backup Configuration",
|
"settings.backup.label": "Backup Configuration",
|
||||||
"settings.backup.hint": "Download all configuration (devices, targets, streams, templates, profiles) as a single JSON file.",
|
"settings.backup.hint": "Download all configuration (devices, targets, streams, templates, profiles) as a single JSON file.",
|
||||||
"settings.backup.button": "Download Backup",
|
"settings.backup.button": "Download Backup",
|
||||||
|
|||||||
@@ -128,6 +128,9 @@
|
|||||||
"device.led_type.hint": "RGB (3 канала) или RGBW (4 канала с выделенным белым)",
|
"device.led_type.hint": "RGB (3 канала) или RGBW (4 канала с выделенным белым)",
|
||||||
"device.send_latency": "Задержка отправки (мс):",
|
"device.send_latency": "Задержка отправки (мс):",
|
||||||
"device.send_latency.hint": "Имитация сетевой/серийной задержки на кадр в миллисекундах",
|
"device.send_latency.hint": "Имитация сетевой/серийной задержки на кадр в миллисекундах",
|
||||||
|
"device.mqtt_topic": "MQTT Топик:",
|
||||||
|
"device.mqtt_topic.hint": "MQTT топик для публикации пиксельных данных (напр. mqtt://ledgrab/device/name)",
|
||||||
|
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/гостиная",
|
||||||
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
|
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
|
||||||
"device.name": "Имя Устройства:",
|
"device.name": "Имя Устройства:",
|
||||||
"device.name.placeholder": "ТВ в Гостиной",
|
"device.name.placeholder": "ТВ в Гостиной",
|
||||||
@@ -529,6 +532,7 @@
|
|||||||
"dashboard.stop_all": "Остановить все",
|
"dashboard.stop_all": "Остановить все",
|
||||||
"dashboard.failed": "Не удалось загрузить обзор",
|
"dashboard.failed": "Не удалось загрузить обзор",
|
||||||
"dashboard.section.profiles": "Профили",
|
"dashboard.section.profiles": "Профили",
|
||||||
|
"dashboard.section.scenes": "Пресеты сцен",
|
||||||
"dashboard.targets": "Цели",
|
"dashboard.targets": "Цели",
|
||||||
"dashboard.section.performance": "Производительность системы",
|
"dashboard.section.performance": "Производительность системы",
|
||||||
"dashboard.perf.cpu": "ЦП",
|
"dashboard.perf.cpu": "ЦП",
|
||||||
@@ -568,6 +572,27 @@
|
|||||||
"profiles.condition.application.match_type.topmost": "На переднем плане",
|
"profiles.condition.application.match_type.topmost": "На переднем плане",
|
||||||
"profiles.condition.application.match_type.topmost_fullscreen": "На переднем плане + Полный экран",
|
"profiles.condition.application.match_type.topmost_fullscreen": "На переднем плане + Полный экран",
|
||||||
"profiles.condition.application.match_type.fullscreen": "Полный экран",
|
"profiles.condition.application.match_type.fullscreen": "Полный экран",
|
||||||
|
"profiles.condition.time_of_day": "Время суток",
|
||||||
|
"profiles.condition.time_of_day.start_time": "Время начала:",
|
||||||
|
"profiles.condition.time_of_day.end_time": "Время окончания:",
|
||||||
|
"profiles.condition.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06:00) укажите время начала позже времени окончания.",
|
||||||
|
"profiles.condition.system_idle": "Бездействие системы",
|
||||||
|
"profiles.condition.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):",
|
||||||
|
"profiles.condition.system_idle.mode": "Режим срабатывания:",
|
||||||
|
"profiles.condition.system_idle.when_idle": "При бездействии",
|
||||||
|
"profiles.condition.system_idle.when_active": "При активности",
|
||||||
|
"profiles.condition.display_state": "Состояние дисплея",
|
||||||
|
"profiles.condition.display_state.state": "Состояние монитора:",
|
||||||
|
"profiles.condition.display_state.on": "Включён",
|
||||||
|
"profiles.condition.display_state.off": "Выключен (спящий режим)",
|
||||||
|
"profiles.condition.mqtt": "MQTT",
|
||||||
|
"profiles.condition.mqtt.topic": "Топик:",
|
||||||
|
"profiles.condition.mqtt.payload": "Значение:",
|
||||||
|
"profiles.condition.mqtt.match_mode": "Режим сравнения:",
|
||||||
|
"profiles.condition.mqtt.match_mode.exact": "Точное совпадение",
|
||||||
|
"profiles.condition.mqtt.match_mode.contains": "Содержит",
|
||||||
|
"profiles.condition.mqtt.match_mode.regex": "Регулярное выражение",
|
||||||
|
"profiles.condition.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику",
|
||||||
"profiles.targets": "Цели:",
|
"profiles.targets": "Цели:",
|
||||||
"profiles.targets.hint": "Цели для запуска при активации профиля",
|
"profiles.targets.hint": "Цели для запуска при активации профиля",
|
||||||
"profiles.targets.empty": "Нет доступных целей",
|
"profiles.targets.empty": "Нет доступных целей",
|
||||||
@@ -586,6 +611,36 @@
|
|||||||
"profiles.error.name_required": "Введите название",
|
"profiles.error.name_required": "Введите название",
|
||||||
"profiles.toggle_all.start": "Запустить все цели",
|
"profiles.toggle_all.start": "Запустить все цели",
|
||||||
"profiles.toggle_all.stop": "Остановить все цели",
|
"profiles.toggle_all.stop": "Остановить все цели",
|
||||||
|
"scenes.title": "Сцены",
|
||||||
|
"scenes.add": "Захватить сцену",
|
||||||
|
"scenes.edit": "Редактировать сцену",
|
||||||
|
"scenes.name": "Название:",
|
||||||
|
"scenes.name.hint": "Описательное имя для этого пресета сцены",
|
||||||
|
"scenes.description": "Описание:",
|
||||||
|
"scenes.description.hint": "Необязательное описание назначения этой сцены",
|
||||||
|
"scenes.color": "Цвет карточки:",
|
||||||
|
"scenes.color.hint": "Акцентный цвет для карточки сцены на панели управления",
|
||||||
|
"scenes.capture": "Захват",
|
||||||
|
"scenes.activate": "Активировать сцену",
|
||||||
|
"scenes.recapture": "Перезахватить текущее состояние",
|
||||||
|
"scenes.delete": "Удалить сцену",
|
||||||
|
"scenes.targets_count": "целей",
|
||||||
|
"scenes.devices_count": "устройств",
|
||||||
|
"scenes.profiles_count": "профилей",
|
||||||
|
"scenes.captured": "Сцена захвачена",
|
||||||
|
"scenes.updated": "Сцена обновлена",
|
||||||
|
"scenes.activated": "Сцена активирована",
|
||||||
|
"scenes.activated_partial": "Сцена активирована частично",
|
||||||
|
"scenes.errors": "ошибок",
|
||||||
|
"scenes.recaptured": "Сцена перезахвачена",
|
||||||
|
"scenes.deleted": "Сцена удалена",
|
||||||
|
"scenes.recapture_confirm": "Перезахватить текущее состояние в \"{name}\"?",
|
||||||
|
"scenes.delete_confirm": "Удалить сцену \"{name}\"?",
|
||||||
|
"scenes.error.name_required": "Необходимо указать название",
|
||||||
|
"scenes.error.save_failed": "Не удалось сохранить сцену",
|
||||||
|
"scenes.error.activate_failed": "Не удалось активировать сцену",
|
||||||
|
"scenes.error.recapture_failed": "Не удалось перезахватить сцену",
|
||||||
|
"scenes.error.delete_failed": "Не удалось удалить сцену",
|
||||||
"autostart.title": "Автозапуск целей",
|
"autostart.title": "Автозапуск целей",
|
||||||
"autostart.toggle.enabled": "Автозапуск включён",
|
"autostart.toggle.enabled": "Автозапуск включён",
|
||||||
"autostart.toggle.disabled": "Автозапуск отключён",
|
"autostart.toggle.disabled": "Автозапуск отключён",
|
||||||
@@ -961,6 +1016,7 @@
|
|||||||
"search.group.pattern_templates": "Шаблоны паттернов",
|
"search.group.pattern_templates": "Шаблоны паттернов",
|
||||||
"search.group.audio": "Аудиоисточники",
|
"search.group.audio": "Аудиоисточники",
|
||||||
"search.group.value": "Источники значений",
|
"search.group.value": "Источники значений",
|
||||||
|
"search.group.scenes": "Пресеты сцен",
|
||||||
"settings.backup.label": "Резервное копирование",
|
"settings.backup.label": "Резервное копирование",
|
||||||
"settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, профили) в виде одного JSON-файла.",
|
"settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, профили) в виде одного JSON-файла.",
|
||||||
"settings.backup.button": "Скачать резервную копию",
|
"settings.backup.button": "Скачать резервную копию",
|
||||||
|
|||||||
@@ -128,6 +128,9 @@
|
|||||||
"device.led_type.hint": "RGB(3通道)或 RGBW(4通道,带独立白色)",
|
"device.led_type.hint": "RGB(3通道)或 RGBW(4通道,带独立白色)",
|
||||||
"device.send_latency": "发送延迟(毫秒):",
|
"device.send_latency": "发送延迟(毫秒):",
|
||||||
"device.send_latency.hint": "每帧模拟网络/串口延迟(毫秒)",
|
"device.send_latency.hint": "每帧模拟网络/串口延迟(毫秒)",
|
||||||
|
"device.mqtt_topic": "MQTT 主题:",
|
||||||
|
"device.mqtt_topic.hint": "用于发布像素数据的 MQTT 主题路径(例如 mqtt://ledgrab/device/name)",
|
||||||
|
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/客厅",
|
||||||
"device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100)",
|
"device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100)",
|
||||||
"device.name": "设备名称:",
|
"device.name": "设备名称:",
|
||||||
"device.name.placeholder": "客厅电视",
|
"device.name.placeholder": "客厅电视",
|
||||||
@@ -529,6 +532,7 @@
|
|||||||
"dashboard.stop_all": "全部停止",
|
"dashboard.stop_all": "全部停止",
|
||||||
"dashboard.failed": "加载仪表盘失败",
|
"dashboard.failed": "加载仪表盘失败",
|
||||||
"dashboard.section.profiles": "配置文件",
|
"dashboard.section.profiles": "配置文件",
|
||||||
|
"dashboard.section.scenes": "场景预设",
|
||||||
"dashboard.targets": "目标",
|
"dashboard.targets": "目标",
|
||||||
"dashboard.section.performance": "系统性能",
|
"dashboard.section.performance": "系统性能",
|
||||||
"dashboard.perf.cpu": "CPU",
|
"dashboard.perf.cpu": "CPU",
|
||||||
@@ -568,6 +572,27 @@
|
|||||||
"profiles.condition.application.match_type.topmost": "最前(前台)",
|
"profiles.condition.application.match_type.topmost": "最前(前台)",
|
||||||
"profiles.condition.application.match_type.topmost_fullscreen": "最前 + 全屏",
|
"profiles.condition.application.match_type.topmost_fullscreen": "最前 + 全屏",
|
||||||
"profiles.condition.application.match_type.fullscreen": "全屏",
|
"profiles.condition.application.match_type.fullscreen": "全屏",
|
||||||
|
"profiles.condition.time_of_day": "时段",
|
||||||
|
"profiles.condition.time_of_day.start_time": "开始时间:",
|
||||||
|
"profiles.condition.time_of_day.end_time": "结束时间:",
|
||||||
|
"profiles.condition.time_of_day.overnight_hint": "跨夜时段(如 22:00–06:00),请将开始时间设为晚于结束时间。",
|
||||||
|
"profiles.condition.system_idle": "系统空闲",
|
||||||
|
"profiles.condition.system_idle.idle_minutes": "空闲超时(分钟):",
|
||||||
|
"profiles.condition.system_idle.mode": "触发模式:",
|
||||||
|
"profiles.condition.system_idle.when_idle": "空闲时",
|
||||||
|
"profiles.condition.system_idle.when_active": "活跃时",
|
||||||
|
"profiles.condition.display_state": "显示器状态",
|
||||||
|
"profiles.condition.display_state.state": "显示器状态:",
|
||||||
|
"profiles.condition.display_state.on": "开启",
|
||||||
|
"profiles.condition.display_state.off": "关闭(休眠)",
|
||||||
|
"profiles.condition.mqtt": "MQTT",
|
||||||
|
"profiles.condition.mqtt.topic": "主题:",
|
||||||
|
"profiles.condition.mqtt.payload": "消息内容:",
|
||||||
|
"profiles.condition.mqtt.match_mode": "匹配模式:",
|
||||||
|
"profiles.condition.mqtt.match_mode.exact": "精确匹配",
|
||||||
|
"profiles.condition.mqtt.match_mode.contains": "包含",
|
||||||
|
"profiles.condition.mqtt.match_mode.regex": "正则表达式",
|
||||||
|
"profiles.condition.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活",
|
||||||
"profiles.targets": "目标:",
|
"profiles.targets": "目标:",
|
||||||
"profiles.targets.hint": "配置文件激活时要启动的目标",
|
"profiles.targets.hint": "配置文件激活时要启动的目标",
|
||||||
"profiles.targets.empty": "没有可用的目标",
|
"profiles.targets.empty": "没有可用的目标",
|
||||||
@@ -586,6 +611,36 @@
|
|||||||
"profiles.error.name_required": "名称为必填项",
|
"profiles.error.name_required": "名称为必填项",
|
||||||
"profiles.toggle_all.start": "启动所有目标",
|
"profiles.toggle_all.start": "启动所有目标",
|
||||||
"profiles.toggle_all.stop": "停止所有目标",
|
"profiles.toggle_all.stop": "停止所有目标",
|
||||||
|
"scenes.title": "场景",
|
||||||
|
"scenes.add": "捕获场景",
|
||||||
|
"scenes.edit": "编辑场景",
|
||||||
|
"scenes.name": "名称:",
|
||||||
|
"scenes.name.hint": "此场景预设的描述性名称",
|
||||||
|
"scenes.description": "描述:",
|
||||||
|
"scenes.description.hint": "此场景功能的可选描述",
|
||||||
|
"scenes.color": "卡片颜色:",
|
||||||
|
"scenes.color.hint": "仪表盘上场景卡片的强调色",
|
||||||
|
"scenes.capture": "捕获",
|
||||||
|
"scenes.activate": "激活场景",
|
||||||
|
"scenes.recapture": "重新捕获当前状态",
|
||||||
|
"scenes.delete": "删除场景",
|
||||||
|
"scenes.targets_count": "目标",
|
||||||
|
"scenes.devices_count": "设备",
|
||||||
|
"scenes.profiles_count": "配置",
|
||||||
|
"scenes.captured": "场景已捕获",
|
||||||
|
"scenes.updated": "场景已更新",
|
||||||
|
"scenes.activated": "场景已激活",
|
||||||
|
"scenes.activated_partial": "场景部分激活",
|
||||||
|
"scenes.errors": "错误",
|
||||||
|
"scenes.recaptured": "场景已重新捕获",
|
||||||
|
"scenes.deleted": "场景已删除",
|
||||||
|
"scenes.recapture_confirm": "将当前状态重新捕获到\"{name}\"中?",
|
||||||
|
"scenes.delete_confirm": "删除场景\"{name}\"?",
|
||||||
|
"scenes.error.name_required": "名称为必填项",
|
||||||
|
"scenes.error.save_failed": "保存场景失败",
|
||||||
|
"scenes.error.activate_failed": "激活场景失败",
|
||||||
|
"scenes.error.recapture_failed": "重新捕获场景失败",
|
||||||
|
"scenes.error.delete_failed": "删除场景失败",
|
||||||
"autostart.title": "自动启动目标",
|
"autostart.title": "自动启动目标",
|
||||||
"autostart.toggle.enabled": "自动启动已启用",
|
"autostart.toggle.enabled": "自动启动已启用",
|
||||||
"autostart.toggle.disabled": "自动启动已禁用",
|
"autostart.toggle.disabled": "自动启动已禁用",
|
||||||
@@ -961,6 +1016,7 @@
|
|||||||
"search.group.pattern_templates": "图案模板",
|
"search.group.pattern_templates": "图案模板",
|
||||||
"search.group.audio": "音频源",
|
"search.group.audio": "音频源",
|
||||||
"search.group.value": "值源",
|
"search.group.value": "值源",
|
||||||
|
"search.group.scenes": "场景预设",
|
||||||
"settings.backup.label": "备份配置",
|
"settings.backup.label": "备份配置",
|
||||||
"settings.backup.hint": "将所有配置(设备、目标、流、模板、配置文件)下载为单个 JSON 文件。",
|
"settings.backup.hint": "将所有配置(设备、目标、流、模板、配置文件)下载为单个 JSON 文件。",
|
||||||
"settings.backup.button": "下载备份",
|
"settings.backup.button": "下载备份",
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ class Condition:
|
|||||||
return AlwaysCondition.from_dict(data)
|
return AlwaysCondition.from_dict(data)
|
||||||
if ct == "application":
|
if ct == "application":
|
||||||
return ApplicationCondition.from_dict(data)
|
return ApplicationCondition.from_dict(data)
|
||||||
|
if ct == "time_of_day":
|
||||||
|
return TimeOfDayCondition.from_dict(data)
|
||||||
|
if ct == "system_idle":
|
||||||
|
return SystemIdleCondition.from_dict(data)
|
||||||
|
if ct == "display_state":
|
||||||
|
return DisplayStateCondition.from_dict(data)
|
||||||
|
if ct == "mqtt":
|
||||||
|
return MQTTCondition.from_dict(data)
|
||||||
raise ValueError(f"Unknown condition type: {ct}")
|
raise ValueError(f"Unknown condition type: {ct}")
|
||||||
|
|
||||||
|
|
||||||
@@ -58,6 +66,98 @@ class ApplicationCondition(Condition):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TimeOfDayCondition(Condition):
|
||||||
|
"""Activate during a specific time range (server local time).
|
||||||
|
|
||||||
|
Supports overnight ranges: if start_time > end_time, the range wraps
|
||||||
|
around midnight (e.g. 22:00 → 06:00).
|
||||||
|
"""
|
||||||
|
|
||||||
|
condition_type: str = "time_of_day"
|
||||||
|
start_time: str = "00:00" # HH:MM
|
||||||
|
end_time: str = "23:59" # HH:MM
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = super().to_dict()
|
||||||
|
d["start_time"] = self.start_time
|
||||||
|
d["end_time"] = self.end_time
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "TimeOfDayCondition":
|
||||||
|
return cls(
|
||||||
|
start_time=data.get("start_time", "00:00"),
|
||||||
|
end_time=data.get("end_time", "23:59"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SystemIdleCondition(Condition):
|
||||||
|
"""Activate based on system idle time (keyboard/mouse inactivity)."""
|
||||||
|
|
||||||
|
condition_type: str = "system_idle"
|
||||||
|
idle_minutes: int = 5
|
||||||
|
when_idle: bool = True # True = active when idle; False = active when NOT idle
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = super().to_dict()
|
||||||
|
d["idle_minutes"] = self.idle_minutes
|
||||||
|
d["when_idle"] = self.when_idle
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "SystemIdleCondition":
|
||||||
|
return cls(
|
||||||
|
idle_minutes=data.get("idle_minutes", 5),
|
||||||
|
when_idle=data.get("when_idle", True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DisplayStateCondition(Condition):
|
||||||
|
"""Activate based on display/monitor power state."""
|
||||||
|
|
||||||
|
condition_type: str = "display_state"
|
||||||
|
state: str = "on" # "on" | "off"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = super().to_dict()
|
||||||
|
d["state"] = self.state
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "DisplayStateCondition":
|
||||||
|
return cls(
|
||||||
|
state=data.get("state", "on"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MQTTCondition(Condition):
|
||||||
|
"""Activate based on an MQTT topic value."""
|
||||||
|
|
||||||
|
condition_type: str = "mqtt"
|
||||||
|
topic: str = ""
|
||||||
|
payload: str = ""
|
||||||
|
match_mode: str = "exact" # "exact" | "contains" | "regex"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = super().to_dict()
|
||||||
|
d["topic"] = self.topic
|
||||||
|
d["payload"] = self.payload
|
||||||
|
d["match_mode"] = self.match_mode
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "MQTTCondition":
|
||||||
|
return cls(
|
||||||
|
topic=data.get("topic", ""),
|
||||||
|
payload=data.get("payload", ""),
|
||||||
|
match_mode=data.get("match_mode", "exact"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Profile:
|
class Profile:
|
||||||
"""Automation profile that activates targets based on conditions."""
|
"""Automation profile that activates targets based on conditions."""
|
||||||
|
|||||||
125
server/src/wled_controller/storage/scene_preset.py
Normal file
125
server/src/wled_controller/storage/scene_preset.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Scene preset data models — snapshot of current system state."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TargetSnapshot:
|
||||||
|
"""Snapshot of a single target's mutable state."""
|
||||||
|
|
||||||
|
target_id: str
|
||||||
|
running: bool = False
|
||||||
|
color_strip_source_id: str = ""
|
||||||
|
brightness_value_source_id: str = ""
|
||||||
|
fps: int = 30
|
||||||
|
auto_start: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"target_id": self.target_id,
|
||||||
|
"running": self.running,
|
||||||
|
"color_strip_source_id": self.color_strip_source_id,
|
||||||
|
"brightness_value_source_id": self.brightness_value_source_id,
|
||||||
|
"fps": self.fps,
|
||||||
|
"auto_start": self.auto_start,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "TargetSnapshot":
|
||||||
|
return cls(
|
||||||
|
target_id=data["target_id"],
|
||||||
|
running=data.get("running", False),
|
||||||
|
color_strip_source_id=data.get("color_strip_source_id", ""),
|
||||||
|
brightness_value_source_id=data.get("brightness_value_source_id", ""),
|
||||||
|
fps=data.get("fps", 30),
|
||||||
|
auto_start=data.get("auto_start", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceBrightnessSnapshot:
|
||||||
|
"""Snapshot of a device's software brightness."""
|
||||||
|
|
||||||
|
device_id: str
|
||||||
|
software_brightness: int = 255
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"device_id": self.device_id,
|
||||||
|
"software_brightness": self.software_brightness,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "DeviceBrightnessSnapshot":
|
||||||
|
return cls(
|
||||||
|
device_id=data["device_id"],
|
||||||
|
software_brightness=data.get("software_brightness", 255),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProfileSnapshot:
|
||||||
|
"""Snapshot of a profile's enabled state."""
|
||||||
|
|
||||||
|
profile_id: str
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"profile_id": self.profile_id,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ProfileSnapshot":
|
||||||
|
return cls(
|
||||||
|
profile_id=data["profile_id"],
|
||||||
|
enabled=data.get("enabled", True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScenePreset:
|
||||||
|
"""A named snapshot of system state that can be restored."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
color: str = "#4fc3f7" # accent color for the card
|
||||||
|
targets: List[TargetSnapshot] = field(default_factory=list)
|
||||||
|
devices: List[DeviceBrightnessSnapshot] = field(default_factory=list)
|
||||||
|
profiles: List[ProfileSnapshot] = field(default_factory=list)
|
||||||
|
order: int = 0
|
||||||
|
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"color": self.color,
|
||||||
|
"targets": [t.to_dict() for t in self.targets],
|
||||||
|
"devices": [d.to_dict() for d in self.devices],
|
||||||
|
"profiles": [p.to_dict() for p in self.profiles],
|
||||||
|
"order": self.order,
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
"updated_at": self.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ScenePreset":
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
name=data["name"],
|
||||||
|
description=data.get("description", ""),
|
||||||
|
color=data.get("color", "#4fc3f7"),
|
||||||
|
targets=[TargetSnapshot.from_dict(t) for t in data.get("targets", [])],
|
||||||
|
devices=[DeviceBrightnessSnapshot.from_dict(d) for d in data.get("devices", [])],
|
||||||
|
profiles=[ProfileSnapshot.from_dict(p) for p in data.get("profiles", [])],
|
||||||
|
order=data.get("order", 0),
|
||||||
|
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||||
|
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||||
|
)
|
||||||
134
server/src/wled_controller/storage/scene_preset_store.py
Normal file
134
server/src/wled_controller/storage/scene_preset_store.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""Scene preset storage using JSON files."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from wled_controller.storage.scene_preset import ScenePreset
|
||||||
|
from wled_controller.utils import atomic_write_json, get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePresetStore:
|
||||||
|
"""Persistent storage for scene presets."""
|
||||||
|
|
||||||
|
def __init__(self, file_path: str):
|
||||||
|
self.file_path = Path(file_path)
|
||||||
|
self._presets: Dict[str, ScenePreset] = {}
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
if not self.file_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
presets_data = data.get("scene_presets", {})
|
||||||
|
loaded = 0
|
||||||
|
for preset_id, preset_dict in presets_data.items():
|
||||||
|
try:
|
||||||
|
preset = ScenePreset.from_dict(preset_dict)
|
||||||
|
self._presets[preset_id] = preset
|
||||||
|
loaded += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load scene preset {preset_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
if loaded > 0:
|
||||||
|
logger.info(f"Loaded {loaded} scene presets from storage")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load scene presets from {self.file_path}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info(f"Scene preset store initialized with {len(self._presets)} presets")
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
try:
|
||||||
|
data = {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scene_presets": {
|
||||||
|
pid: p.to_dict() for pid, p in self._presets.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
atomic_write_json(self.file_path, data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save scene presets to {self.file_path}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_all_presets(self) -> List[ScenePreset]:
|
||||||
|
return sorted(self._presets.values(), key=lambda p: p.order)
|
||||||
|
|
||||||
|
def get_preset(self, preset_id: str) -> ScenePreset:
|
||||||
|
if preset_id not in self._presets:
|
||||||
|
raise ValueError(f"Scene preset not found: {preset_id}")
|
||||||
|
return self._presets[preset_id]
|
||||||
|
|
||||||
|
def create_preset(self, preset: ScenePreset) -> ScenePreset:
|
||||||
|
for p in self._presets.values():
|
||||||
|
if p.name == preset.name:
|
||||||
|
raise ValueError(f"Scene preset with name '{preset.name}' already exists")
|
||||||
|
|
||||||
|
self._presets[preset.id] = preset
|
||||||
|
self._save()
|
||||||
|
logger.info(f"Created scene preset: {preset.name} ({preset.id})")
|
||||||
|
return preset
|
||||||
|
|
||||||
|
def update_preset(
|
||||||
|
self,
|
||||||
|
preset_id: str,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
color: Optional[str] = None,
|
||||||
|
order: Optional[int] = None,
|
||||||
|
) -> ScenePreset:
|
||||||
|
if preset_id not in self._presets:
|
||||||
|
raise ValueError(f"Scene preset not found: {preset_id}")
|
||||||
|
|
||||||
|
preset = self._presets[preset_id]
|
||||||
|
|
||||||
|
if name is not None:
|
||||||
|
for pid, p in self._presets.items():
|
||||||
|
if pid != preset_id and p.name == name:
|
||||||
|
raise ValueError(f"Scene preset with name '{name}' already exists")
|
||||||
|
preset.name = name
|
||||||
|
if description is not None:
|
||||||
|
preset.description = description
|
||||||
|
if color is not None:
|
||||||
|
preset.color = color
|
||||||
|
if order is not None:
|
||||||
|
preset.order = order
|
||||||
|
|
||||||
|
preset.updated_at = datetime.utcnow()
|
||||||
|
self._save()
|
||||||
|
logger.info(f"Updated scene preset: {preset_id}")
|
||||||
|
return preset
|
||||||
|
|
||||||
|
def recapture_preset(self, preset_id: str, preset: ScenePreset) -> ScenePreset:
|
||||||
|
"""Replace snapshot data of an existing preset (recapture current state)."""
|
||||||
|
if preset_id not in self._presets:
|
||||||
|
raise ValueError(f"Scene preset not found: {preset_id}")
|
||||||
|
|
||||||
|
existing = self._presets[preset_id]
|
||||||
|
existing.targets = preset.targets
|
||||||
|
existing.devices = preset.devices
|
||||||
|
existing.profiles = preset.profiles
|
||||||
|
existing.updated_at = datetime.utcnow()
|
||||||
|
self._save()
|
||||||
|
logger.info(f"Recaptured scene preset: {preset_id}")
|
||||||
|
return existing
|
||||||
|
|
||||||
|
def delete_preset(self, preset_id: str) -> None:
|
||||||
|
if preset_id not in self._presets:
|
||||||
|
raise ValueError(f"Scene preset not found: {preset_id}")
|
||||||
|
|
||||||
|
del self._presets[preset_id]
|
||||||
|
self._save()
|
||||||
|
logger.info(f"Deleted scene preset: {preset_id}")
|
||||||
|
|
||||||
|
def count(self) -> int:
|
||||||
|
return len(self._presets)
|
||||||
@@ -85,6 +85,7 @@
|
|||||||
<button class="tab-btn" data-tab="profiles" onclick="switchTab('profiles')" role="tab" aria-selected="false" aria-controls="tab-profiles" id="tab-btn-profiles" title="Ctrl+2"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="profiles.title">Profiles</span><span class="tab-badge" id="tab-badge-profiles" style="display:none"></span></button>
|
<button class="tab-btn" data-tab="profiles" onclick="switchTab('profiles')" role="tab" aria-selected="false" aria-controls="tab-profiles" id="tab-btn-profiles" title="Ctrl+2"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="profiles.title">Profiles</span><span class="tab-badge" id="tab-badge-profiles" style="display:none"></span></button>
|
||||||
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><svg class="icon" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg> <span data-i18n="targets.title">Targets</span><span class="tab-badge" id="tab-badge-targets" style="display:none"></span></button>
|
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><svg class="icon" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg> <span data-i18n="targets.title">Targets</span><span class="tab-badge" id="tab-badge-targets" style="display:none"></span></button>
|
||||||
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button>
|
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button>
|
||||||
|
<button class="tab-btn" data-tab="scenes" onclick="switchTab('scenes')" role="tab" aria-selected="false" aria-controls="tab-scenes" id="tab-btn-scenes" title="Ctrl+5"><svg class="icon" viewBox="0 0 24 24"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg> <span data-i18n="scenes.title">Scenes</span><span class="tab-badge" id="tab-badge-scenes" style="display:none"></span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
|
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
|
||||||
@@ -111,6 +112,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-scenes" role="tabpanel" aria-labelledby="tab-btn-scenes">
|
||||||
|
<div id="scenes-content">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Apply saved tab immediately during parse to prevent visible jump
|
// Apply saved tab immediately during parse to prevent visible jump
|
||||||
(function() {
|
(function() {
|
||||||
@@ -153,6 +160,7 @@
|
|||||||
{% include 'modals/stream.html' %}
|
{% include 'modals/stream.html' %}
|
||||||
{% include 'modals/pp-template.html' %}
|
{% include 'modals/pp-template.html' %}
|
||||||
{% include 'modals/profile-editor.html' %}
|
{% include 'modals/profile-editor.html' %}
|
||||||
|
{% include 'modals/scene-preset-editor.html' %}
|
||||||
{% include 'modals/audio-source-editor.html' %}
|
{% include 'modals/audio-source-editor.html' %}
|
||||||
{% include 'modals/test-audio-source.html' %}
|
{% include 'modals/test-audio-source.html' %}
|
||||||
{% include 'modals/audio-template.html' %}
|
{% include 'modals/audio-template.html' %}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<option value="wled">WLED</option>
|
<option value="wled">WLED</option>
|
||||||
<option value="adalight">Adalight</option>
|
<option value="adalight">Adalight</option>
|
||||||
<option value="ambiled">AmbiLED</option>
|
<option value="ambiled">AmbiLED</option>
|
||||||
|
<option value="mqtt">MQTT</option>
|
||||||
<option value="mock">Mock</option>
|
<option value="mock">Mock</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<!-- Scene Preset Editor Modal -->
|
||||||
|
<div id="scene-preset-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="scene-preset-editor-title">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="scene-preset-editor-title"><svg class="icon" viewBox="0 0 24 24"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg> <span data-i18n="scenes.add">Capture Scene</span></h2>
|
||||||
|
<button class="modal-close-btn" onclick="closeScenePresetEditor()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="scene-preset-editor-form">
|
||||||
|
<input type="hidden" id="scene-preset-editor-id">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="scene-preset-editor-name" data-i18n="scenes.name">Name:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="scenes.name.hint">A descriptive name for this scene preset</small>
|
||||||
|
<input type="text" id="scene-preset-editor-name" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="scene-preset-editor-description" data-i18n="scenes.description">Description:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="scenes.description.hint">Optional description of what this scene does</small>
|
||||||
|
<input type="text" id="scene-preset-editor-description">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="scene-preset-editor-color" data-i18n="scenes.color">Card Color:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="scenes.color.hint">Accent color for the scene card on the dashboard</small>
|
||||||
|
<input type="color" id="scene-preset-editor-color" value="#4fc3f7">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="scene-preset-editor-error" class="error-message" style="display: none;"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="closeScenePresetEditor()" title="Cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||||
|
<button class="btn btn-icon btn-primary" onclick="saveScenePreset()" title="Save" data-i18n-aria-label="aria.save">✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user