diff --git a/TODO.md b/TODO.md index 4afe158..ed48db0 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,134 @@ # LedGrab TODO +## Multi-broker MQTT refactor + +Goal: drop the global `MQTTService` / `MQTTConfig`. Every MQTT consumer +references an `MQTTSource.id`; `MQTTManager` is the only entry point. +`MQTTManager` + `MQTTRuntime` already exist — the job is to migrate every +caller off the legacy path, then delete it. + +### Phase 1 — `mqtt_source_id` on Z2M target + +- [x] Field on `Z2MLightOutputTarget` storage dataclass (+ to/from_dict) +- [x] Field on Z2M create/update/response schemas +- [x] Validate referenced `MQTTSource` exists at create/update +- [x] Thread through `output_target_store.create_z2m_light_target` + update +- [x] Thread through `ProcessorManager.add_z2m_light_target` +- [x] Thread through `Z2MLightTargetProcessor` constructor + +### Phase 2 — Z2M processor uses `MQTTManager` + +- [x] Replace `_mqtt_service` with `_mqtt_runtime` acquired from manager +- [x] `start()` acquire / `stop()` release +- [x] `_publish_payload` → `self._mqtt_runtime.publish(...)` +- [x] `turn_off_lights` borrow-pattern via manager (mirror HA-light) +- [x] Add `mqtt_manager` to `ProcessorDependencies` / `TargetContext` + +### Phase 3 — Z2M editor UI + +- [x] Add MQTT broker `EntitySelect` in Routing +- [x] Reuse `mqttSourcesCache` +- [x] Wire `mqtt_source_id` into edit-load + save payload + validation + +### Phase 4 — DIY MQTT device (`MQTTLEDClient`) + +- [x] `mqtt_source_id` field on `Device` storage +- [x] Field on `device_config.MQTTConfig` +- [x] `MQTTLEDClient` acquires runtime in `connect()`, releases in `close()` +- [x] Provider threads `mqtt_manager` via `ProviderDeps` +- [ ] Device editor: MQTT source picker shown for `device_type=mqtt` *(UI still + pending — backend accepts the field, but the device-create form doesn't + expose it yet)* + +### Phase 5 — `AutomationEngine` + +- [x] Drop `mqtt_service` ctor parameter +- [x] Drop legacy fallback in `_evaluate_mqtt` (rule must reference a source) + +### Phase 6 — `api/routes/system.py` + +- [x] Replace integration status with `mqtt_manager.get_all_sources_status()` +- [ ] Update frontend dashboard payload (MQTT widget now expects a list of + sources instead of a single `enabled`/`connected` pair — surface in UI) + +### Phase 7 — Startup migration + +- [x] Seed a "Default Broker" `MQTTSource` if legacy YAML / env had a + broker configured and the store is empty (`core.mqtt.legacy_migration`) +- [x] Deprecation warning logged on migration; YAML/env no longer read after + +### Phase 8 — Remove legacy + +- [x] Delete `core/mqtt/mqtt_service.py` +- [x] Delete `set_mqtt_service` / `get_mqtt_service` (mqtt_client.py) +- [x] Remove `MQTTService` from `main.py` +- [x] Remove `MQTTConfig` + `resolve_mqtt_password` from `config.py` +- [x] Remove `mqtt: MQTTConfig` from `Config` (with `extra="ignore"` so legacy + YAML still loads) + +### Phase 9 — Verification + +- [x] `pytest tests/ --no-cov -q` clean (973 passing; removed obsolete + `test_default_mqtt_disabled`) +- [x] `ruff check src/` clean +- [x] `tsc --noEmit` + `npm run build` +- [ ] Smoke test: Z2M target on a configured MQTT Source publishes to broker + (manual) + +## Refactor: typed output-target factories + auto-registry + +Replaced `target_type` string elif chains in `OutputTargetStore` and +`OutputTarget.from_dict` with: (1) `__init_subclass__` registry for +deserialization, (2) per-type typed `create_*_target` / +`update_*_target` methods called directly from the route layer's +`match data:` dispatch. API contract unchanged, no DB migration. + +### Phase 1 — Registry on `OutputTarget` + +- [x] Added `_registry` + `_type_key` ClassVars + `__init_subclass__(*, type_key)` +- [x] Rewrote `OutputTarget.from_dict` to dispatch via registry +- [x] Declared `type_key="led"` / `"ha_light"` / `"z2m_light"` on the three subclasses + +### Phase 2 — Typed `create_*_target` methods + +- [x] Extracted `_resolve_brightness`, `_resolve_transition`, `_check_unique_name`, + `_new_id_and_now`, `_finalize` helpers on the store +- [x] Added `create_wled_target` / `create_ha_light_target` / `create_z2m_light_target` + with per-type defaults (transition 0.5/0.3, update_rate 2.0/5.0) baked into + their signatures + +### Phase 3 — Typed `update_*_target` methods + +- [x] Added `update_wled_target` / `update_ha_light_target` / `update_z2m_light_target` + with `_begin_update` / `_commit_update` helpers +- [x] Each typed update method validates the target's class before mutating + +### Phase 4 — Route migration + +- [x] `create_target` route uses `match data:` to call typed store methods — + no more `getattr(data, "x", default)` pyramid +- [x] `update_target` route uses `match data:` and computes `settings_changed` / + `css_changed` / `brightness_changed` per-arm from typed fields +- [x] Helpers `_build_ha_mappings`, `_build_z2m_mappings`, + `_validate_device_exists`, `_resolve_effective_color_vs_id` extracted + +### Phase 5 — Decision: keep both shims + +After grepping for callers, `src/ledgrab/core/scenes/scene_activator.py:90` +calls `target_store.update_target(target_id, **changed)` with a dynamically +built dict — it legitimately doesn't know the target's type at the call site. +The shims are now ~30-line dispatchers that route to typed methods (no more +inline construction elif chains), so the original anti-pattern is gone while +the generic API remains available for "don't-know-the-type" callers like the +scene activator. Tests continue to use the shorthand `create_target("A", "led")` +form without churn. + +### Phase 6 — Verify + +- [x] `ruff check` clean on all modified files +- [x] `py -3.13 -m pytest tests/ --no-cov -q` — 974 passed (was 974 before) +- [ ] Manual smoke test in UI: create/edit/delete each of the three target types + ## Custom card icons — extend to all card types Migrate the existing icon-plate work (devices, LED targets, HA-light targets) @@ -131,7 +260,7 @@ Branch: `feat/device-event-notifications`. Default ON. permission row + Test-notification button. - [x] i18n: `settings.notifications.*` and `notifications.*` keys in en/ru/zh. -### Verification +### Verification (notifications) - [x] `npx tsc --noEmit` clean, `npm run build` produces 2.5 MB bundle. - [x] `ruff check src/ tests/` clean. 899/899 pytest pass. diff --git a/server/src/ledgrab/__init__.py b/server/src/ledgrab/__init__.py index ebdc781..b72067f 100644 --- a/server/src/ledgrab/__init__.py +++ b/server/src/ledgrab/__init__.py @@ -1,20 +1,52 @@ """LED Grab - Ambient lighting based on screen content.""" from importlib.metadata import version, PackageNotFoundError +from pathlib import Path -# Fallback version — kept in sync with pyproject.toml. MUST match the -# version declared there on every release. The Windows installer build -# (build/build-dist.ps1) also patches this literal to the resolved build -# version, so any drift here is corrected for bundled distributions. -# Used when the package isn't pip-installed (e.g. embedded via Chaquopy -# on Android, where the source is included directly via source sets, or -# in the Windows bundle where the installed dist-info is stripped). +# Fallback version — patched at build time by build/build-dist.ps1 so the +# bundled Windows distribution reports the release version (the installer +# strips ledgrab-*.dist-info, so importlib.metadata fails there). +# In dev (running from source without `pip install -e .`) and on Android +# (Chaquopy embeds the source directly with no dist-info), we additionally +# read pyproject.toml so the version is always correct without manual sync. _FALLBACK_VERSION = "0.4.2" -try: - __version__ = version("ledgrab") -except PackageNotFoundError: - __version__ = _FALLBACK_VERSION + +def _read_pyproject_version() -> str | None: + """Read version from pyproject.toml (server/pyproject.toml relative to this file). + + Returns None if the file is absent (typical for installed/bundled distributions + where pyproject.toml isn't shipped) or unreadable. + """ + try: + # __init__.py -> ledgrab/ -> src/ -> server/ + pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml" + if not pyproject.is_file(): + return None + try: + import tomllib # Python 3.11+ + except ImportError: + return None + with pyproject.open("rb") as f: + data = tomllib.load(f) + v = data.get("project", {}).get("version") + return v if isinstance(v, str) else None + except Exception: + return None + + +# Prefer pyproject.toml when it sits next to the source (dev checkout). This +# avoids stale `pip install -e .` dist-info pinning an older version after a +# bump. When pyproject.toml isn't shipped (installed packages, Windows bundle, +# Android), fall back to importlib.metadata, then the patched literal. +_live = _read_pyproject_version() +if _live: + __version__ = _live +else: + try: + __version__ = version("ledgrab") + except PackageNotFoundError: + __version__ = _FALLBACK_VERSION __author__ = "Alexei Dolgolyov" __email__ = "dolgolyov.alexei@gmail.com" diff --git a/server/src/ledgrab/api/routes/output_targets.py b/server/src/ledgrab/api/routes/output_targets.py index 1dae68d..6d90fa4 100644 --- a/server/src/ledgrab/api/routes/output_targets.py +++ b/server/src/ledgrab/api/routes/output_targets.py @@ -1,7 +1,7 @@ """Output target routes: CRUD endpoints and batch state/metrics queries.""" import asyncio -from typing import Annotated +from typing import Annotated, Optional from fastapi import APIRouter, Body, HTTPException, Depends @@ -9,18 +9,27 @@ from ledgrab.api.auth import AuthRequired from ledgrab.api.dependencies import ( fire_entity_event, get_device_store, + get_mqtt_store, get_output_target_store, get_processor_manager, get_value_source_store, ) from ledgrab.api.schemas.output_targets import ( HALightMappingSchema, + HALightOutputTargetCreate, HALightOutputTargetResponse, + HALightOutputTargetUpdate, + LedOutputTargetCreate, LedOutputTargetResponse, + LedOutputTargetUpdate, OutputTargetCreate, OutputTargetListResponse, OutputTargetResponse, OutputTargetUpdate, + Z2MLightMappingSchema, + Z2MLightOutputTargetCreate, + Z2MLightOutputTargetResponse, + Z2MLightOutputTargetUpdate, ) from ledgrab.core.processing.processor_manager import ProcessorManager from ledgrab.storage import DeviceStore @@ -30,6 +39,11 @@ from ledgrab.storage.ha_light_output_target import ( HALightMapping, HALightOutputTarget, ) +from ledgrab.storage.z2m_light_output_target import ( + Z2MLightMapping, + Z2MLightOutputTarget, +) +from ledgrab.storage.mqtt_source_store import MQTTSourceStore from ledgrab.storage.output_target_store import OutputTargetStore from ledgrab.storage.value_source_store import ValueSourceStore from ledgrab.utils import get_logger @@ -99,6 +113,42 @@ def _ha_light_target_to_response( ) +def _z2m_light_target_to_response( + target: Z2MLightOutputTarget, +) -> Z2MLightOutputTargetResponse: + """Convert a Z2MLightOutputTarget to Z2MLightOutputTargetResponse.""" + return Z2MLightOutputTargetResponse( + id=target.id, + name=target.name, + mqtt_source_id=target.mqtt_source_id or "", + source_kind=target.source_kind if target.source_kind in ("css", "color_vs") else "css", + color_strip_source_id=target.color_strip_source_id or "", + color_value_source_id=target.color_value_source_id or "", + brightness=target.brightness.to_dict(), + z2m_light_mappings=[ + Z2MLightMappingSchema( + friendly_name=m.friendly_name, + led_start=m.led_start, + led_end=m.led_end, + brightness_scale=m.brightness_scale.to_dict(), + ) + for m in target.light_mappings + ], + base_topic=target.base_topic, + update_rate=target.update_rate.to_dict(), + transition=target.transition.to_dict(), + color_tolerance=target.color_tolerance.to_dict(), + min_brightness_threshold=target.min_brightness_threshold.to_dict(), + stop_action=target.stop_action if target.stop_action in ("none", "turn_off") else "none", + description=target.description, + tags=target.tags, + icon=getattr(target, "icon", "") or "", + icon_color=getattr(target, "icon_color", "") or "", + created_at=target.created_at, + updated_at=target.updated_at, + ) + + def _validate_color_value_source( value_source_store: ValueSourceStore, color_value_source_id: str ) -> None: @@ -131,6 +181,8 @@ def _target_to_response(target) -> OutputTargetResponse: return _led_target_to_response(target) elif isinstance(target, HALightOutputTarget): return _ha_light_target_to_response(target) + elif isinstance(target, Z2MLightOutputTarget): + return _z2m_light_target_to_response(target) else: # Fallback for unknown types — use LED response with defaults return LedOutputTargetResponse( @@ -146,6 +198,57 @@ def _target_to_response(target) -> OutputTargetResponse: # ===== CRUD ENDPOINTS ===== +def _build_ha_mappings( + payload: list[HALightMappingSchema] | None, +) -> list[HALightMapping] | None: + if not payload: + return None + return [ + HALightMapping( + entity_id=m.entity_id, + led_start=m.led_start, + led_end=m.led_end, + brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0), + ) + for m in payload + ] + + +def _build_z2m_mappings( + payload: list[Z2MLightMappingSchema] | None, +) -> list[Z2MLightMapping] | None: + if not payload: + return None + return [ + Z2MLightMapping( + friendly_name=m.friendly_name, + led_start=m.led_start, + led_end=m.led_end, + brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0), + ) + for m in payload + ] + + +def _validate_device_exists(device_store: DeviceStore, device_id: str) -> None: + if not device_id: + return + try: + device_store.get_device(device_id) + except ValueError: + raise HTTPException(status_code=422, detail=f"Device {device_id} not found") + + +def _validate_mqtt_source_exists(mqtt_store: MQTTSourceStore, mqtt_source_id: str) -> None: + """Ensure the referenced MQTT source exists. Empty id is allowed (unconfigured).""" + if not mqtt_source_id: + return + try: + mqtt_store.get(mqtt_source_id) + except (ValueError, EntityNotFoundError): + raise HTTPException(status_code=422, detail=f"MQTT source {mqtt_source_id} not found") + + @router.post( "/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201 ) @@ -156,65 +259,69 @@ async def create_target( device_store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), value_source_store: ValueSourceStore = Depends(get_value_source_store), + mqtt_store: MQTTSourceStore = Depends(get_mqtt_store), ): """Create a new output target.""" try: - # Validate device exists if provided - device_id = getattr(data, "device_id", "") - if device_id: - try: - device_store.get_device(device_id) - except ValueError: - raise HTTPException(status_code=422, detail=f"Device {device_id} not found") - - # Validate color VS reference for HA-light targets in color_vs mode - if ( - getattr(data, "target_type", "") == "ha_light" - and getattr(data, "source_kind", "css") == "color_vs" - ): - _validate_color_value_source( - value_source_store, getattr(data, "color_value_source_id", "") - ) - - ha_light_mappings_raw = getattr(data, "ha_light_mappings", None) - ha_mappings = ( - [ - HALightMapping( - entity_id=m.entity_id, - led_start=m.led_start, - led_end=m.led_end, - brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0), + match data: + case LedOutputTargetCreate(): + _validate_device_exists(device_store, data.device_id) + target = target_store.create_wled_target( + name=data.name, + description=data.description, + tags=data.tags, + device_id=data.device_id, + color_strip_source_id=data.color_strip_source_id, + brightness=data.brightness, + fps=data.fps, + keepalive_interval=data.keepalive_interval, + state_check_interval=data.state_check_interval, + min_brightness_threshold=data.min_brightness_threshold, + adaptive_fps=data.adaptive_fps, + protocol=data.protocol, ) - for m in ha_light_mappings_raw - ] - if ha_light_mappings_raw - else None - ) - - # Create in store - target = target_store.create_target( - name=data.name, - target_type=data.target_type, - device_id=device_id, - color_strip_source_id=getattr(data, "color_strip_source_id", ""), - brightness=getattr(data, "brightness", 1.0), - fps=getattr(data, "fps", 30), - keepalive_interval=getattr(data, "keepalive_interval", 1.0), - state_check_interval=getattr(data, "state_check_interval", 30), - min_brightness_threshold=getattr(data, "min_brightness_threshold", 0), - adaptive_fps=getattr(data, "adaptive_fps", False), - protocol=getattr(data, "protocol", "ddp"), - description=data.description, - tags=data.tags, - ha_source_id=getattr(data, "ha_source_id", ""), - source_kind=getattr(data, "source_kind", "css"), - color_value_source_id=getattr(data, "color_value_source_id", ""), - ha_light_mappings=ha_mappings, - update_rate=getattr(data, "update_rate", 2.0), - transition=getattr(data, "transition", 0.5), - color_tolerance=getattr(data, "color_tolerance", 5), - stop_action=getattr(data, "stop_action", "none"), - ) + case HALightOutputTargetCreate(): + if data.source_kind == "color_vs": + _validate_color_value_source(value_source_store, data.color_value_source_id) + target = target_store.create_ha_light_target( + name=data.name, + description=data.description, + tags=data.tags, + ha_source_id=data.ha_source_id, + source_kind=data.source_kind, + color_strip_source_id=data.color_strip_source_id, + color_value_source_id=data.color_value_source_id, + brightness=data.brightness, + ha_light_mappings=_build_ha_mappings(data.ha_light_mappings), + update_rate=data.update_rate, + transition=data.transition, + min_brightness_threshold=data.min_brightness_threshold, + color_tolerance=data.color_tolerance, + stop_action=data.stop_action, + ) + case Z2MLightOutputTargetCreate(): + if data.source_kind == "color_vs": + _validate_color_value_source(value_source_store, data.color_value_source_id) + _validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id) + target = target_store.create_z2m_light_target( + name=data.name, + description=data.description, + tags=data.tags, + mqtt_source_id=data.mqtt_source_id, + source_kind=data.source_kind, + color_strip_source_id=data.color_strip_source_id, + color_value_source_id=data.color_value_source_id, + brightness=data.brightness, + z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings), + base_topic=data.base_topic, + update_rate=data.update_rate, + transition=data.transition, + min_brightness_threshold=data.min_brightness_threshold, + color_tolerance=data.color_tolerance, + stop_action=data.stop_action, + ) + case _: # pragma: no cover — Pydantic discriminator already ensures one of the three + raise HTTPException(status_code=400, detail="Unknown target_type") # Register in processor manager try: @@ -282,6 +389,18 @@ async def get_target( raise HTTPException(status_code=404, detail=str(e)) +def _resolve_effective_color_vs_id( + target_store: OutputTargetStore, target_id: str, payload_id: Optional[str] +) -> str: + if payload_id is not None: + return payload_id + try: + existing = target_store.get_target(target_id) + except ValueError: + return "" + return getattr(existing, "color_value_source_id", "") or "" + + @router.put( "/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"] ) @@ -293,116 +412,160 @@ async def update_target( device_store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), value_source_store: ValueSourceStore = Depends(get_value_source_store), + mqtt_store: MQTTSourceStore = Depends(get_mqtt_store), ): """Update a output target.""" try: - # Validate device exists if changing - device_id = getattr(data, "device_id", None) - if device_id is not None and device_id: - try: - device_store.get_device(device_id) - except ValueError: - raise HTTPException(status_code=422, detail=f"Device {device_id} not found") + css_changed = False + brightness_changed = False + settings_changed = False + device_changed = False - # Validate color VS reference for HA-light targets switching into / staying in color_vs - if getattr(data, "target_type", "") == "ha_light": - new_kind = getattr(data, "source_kind", None) - new_color_vs = getattr(data, "color_value_source_id", None) - if new_kind == "color_vs" or (new_kind is None and new_color_vs): - # Determine effective id: payload id if provided, else existing target's id - effective_id = new_color_vs - if effective_id is None: - try: - existing = target_store.get_target(target_id) - effective_id = getattr(existing, "color_value_source_id", "") - except ValueError: - effective_id = "" - _validate_color_value_source(value_source_store, effective_id or "") - - # Build HA light mappings if provided - ha_light_mappings_raw = getattr(data, "ha_light_mappings", None) - ha_mappings = None - if ha_light_mappings_raw is not None: - ha_mappings = [ - HALightMapping( - entity_id=m.entity_id, - led_start=m.led_start, - led_end=m.led_end, - brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0), + match data: + case LedOutputTargetUpdate(): + if data.device_id: + _validate_device_exists(device_store, data.device_id) + target = target_store.update_wled_target( + target_id, + name=data.name, + description=data.description, + tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, + device_id=data.device_id, + color_strip_source_id=data.color_strip_source_id, + brightness=data.brightness, + fps=data.fps, + keepalive_interval=data.keepalive_interval, + state_check_interval=data.state_check_interval, + min_brightness_threshold=data.min_brightness_threshold, + adaptive_fps=data.adaptive_fps, + protocol=data.protocol, ) - for m in ha_light_mappings_raw - ] - - # Update in store - target = target_store.update_target( - target_id=target_id, - name=data.name, - device_id=device_id, - color_strip_source_id=getattr(data, "color_strip_source_id", None), - brightness=getattr(data, "brightness", None), - fps=getattr(data, "fps", None), - keepalive_interval=getattr(data, "keepalive_interval", None), - state_check_interval=getattr(data, "state_check_interval", None), - min_brightness_threshold=getattr(data, "min_brightness_threshold", None), - adaptive_fps=getattr(data, "adaptive_fps", None), - protocol=getattr(data, "protocol", None), - description=data.description, - tags=data.tags, - icon=data.icon, - icon_color=data.icon_color, - ha_source_id=getattr(data, "ha_source_id", None), - source_kind=getattr(data, "source_kind", None), - color_value_source_id=getattr(data, "color_value_source_id", None), - ha_light_mappings=ha_mappings, - update_rate=getattr(data, "update_rate", None), - transition=getattr(data, "transition", None), - color_tolerance=getattr(data, "color_tolerance", None), - stop_action=getattr(data, "stop_action", None), - ) + css_changed = data.color_strip_source_id is not None + brightness_changed = data.brightness is not None + settings_changed = any( + v is not None + for v in ( + data.fps, + data.keepalive_interval, + data.state_check_interval, + data.min_brightness_threshold, + data.adaptive_fps, + data.brightness, + ) + ) + device_changed = data.device_id is not None + case HALightOutputTargetUpdate(): + # Validate color VS when switching into / staying in color_vs mode + if data.source_kind == "color_vs" or ( + data.source_kind is None and data.color_value_source_id + ): + effective_id = _resolve_effective_color_vs_id( + target_store, target_id, data.color_value_source_id + ) + _validate_color_value_source(value_source_store, effective_id) + target = target_store.update_ha_light_target( + target_id, + name=data.name, + description=data.description, + tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, + ha_source_id=data.ha_source_id, + source_kind=data.source_kind, + color_strip_source_id=data.color_strip_source_id, + color_value_source_id=data.color_value_source_id, + brightness=data.brightness, + ha_light_mappings=_build_ha_mappings(data.ha_light_mappings), + update_rate=data.update_rate, + transition=data.transition, + min_brightness_threshold=data.min_brightness_threshold, + color_tolerance=data.color_tolerance, + stop_action=data.stop_action, + ) + css_changed = data.color_strip_source_id is not None + brightness_changed = data.brightness is not None + settings_changed = any( + v is not None + for v in ( + data.source_kind, + data.color_value_source_id, + data.brightness, + data.update_rate, + data.transition, + data.min_brightness_threshold, + data.color_tolerance, + data.ha_light_mappings, + data.stop_action, + ) + ) + case Z2MLightOutputTargetUpdate(): + if data.source_kind == "color_vs" or ( + data.source_kind is None and data.color_value_source_id + ): + effective_id = _resolve_effective_color_vs_id( + target_store, target_id, data.color_value_source_id + ) + _validate_color_value_source(value_source_store, effective_id) + if data.mqtt_source_id: + _validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id) + target = target_store.update_z2m_light_target( + target_id, + name=data.name, + description=data.description, + tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, + mqtt_source_id=data.mqtt_source_id, + source_kind=data.source_kind, + color_strip_source_id=data.color_strip_source_id, + color_value_source_id=data.color_value_source_id, + brightness=data.brightness, + z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings), + base_topic=data.base_topic, + update_rate=data.update_rate, + transition=data.transition, + min_brightness_threshold=data.min_brightness_threshold, + color_tolerance=data.color_tolerance, + stop_action=data.stop_action, + ) + css_changed = data.color_strip_source_id is not None + brightness_changed = data.brightness is not None + settings_changed = any( + v is not None + for v in ( + data.source_kind, + data.color_value_source_id, + data.mqtt_source_id, + data.brightness, + data.base_topic, + data.update_rate, + data.transition, + data.min_brightness_threshold, + data.color_tolerance, + data.z2m_light_mappings, + data.stop_action, + ) + ) + case _: # pragma: no cover — Pydantic discriminator already ensures one of the three + raise HTTPException(status_code=400, detail="Unknown target_type") # Sync processor manager (run in thread — css release/acquire can block) - color_strip_source_id = getattr(data, "color_strip_source_id", None) - fps = getattr(data, "fps", None) - keepalive_interval = getattr(data, "keepalive_interval", None) - state_check_interval = getattr(data, "state_check_interval", None) - min_brightness_threshold = getattr(data, "min_brightness_threshold", None) - adaptive_fps = getattr(data, "adaptive_fps", None) - update_rate = getattr(data, "update_rate", None) - transition = getattr(data, "transition", None) - color_tolerance = getattr(data, "color_tolerance", None) - brightness = getattr(data, "brightness", None) - stop_action = getattr(data, "stop_action", None) - source_kind = getattr(data, "source_kind", None) - color_value_source_id = getattr(data, "color_value_source_id", None) - try: await asyncio.to_thread( target.sync_with_manager, manager, - settings_changed=( - fps is not None - or keepalive_interval is not None - or state_check_interval is not None - or min_brightness_threshold is not None - or adaptive_fps is not None - or update_rate is not None - or transition is not None - or color_tolerance is not None - or ha_light_mappings_raw is not None - or brightness is not None - or stop_action is not None - or source_kind is not None - or color_value_source_id is not None - ), - css_changed=color_strip_source_id is not None, - brightness_changed=brightness is not None, + settings_changed=settings_changed, + css_changed=css_changed, + brightness_changed=brightness_changed, ) except ValueError as e: logger.debug("Processor config update skipped for target %s: %s", target_id, e) pass - # Device change requires async stop -> swap -> start cycle - if device_id is not None: + # LED-only: device change requires async stop -> swap -> start cycle + if device_changed and isinstance(target, WledOutputTarget): try: await manager.update_target_device(target_id, target.device_id) except ValueError as e: diff --git a/server/src/ledgrab/api/routes/output_targets_control.py b/server/src/ledgrab/api/routes/output_targets_control.py index 073567a..43eb818 100644 --- a/server/src/ledgrab/api/routes/output_targets_control.py +++ b/server/src/ledgrab/api/routes/output_targets_control.py @@ -335,6 +335,35 @@ async def get_overlay_status( raise HTTPException(status_code=404, detail=str(e)) +# ===== HA LIGHT — MANUAL TURN OFF ===== + + +@router.post("/api/v1/output-targets/{target_id}/ha-light/turn-off", tags=["Processing"]) +async def turn_off_ha_light_target( + target_id: str, + _auth: AuthRequired, + target_store: OutputTargetStore = Depends(get_output_target_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Turn off all HA light entities mapped by the target. + + Works regardless of whether the target's processor is running. Useful + when ``stop_action`` is ``"none"`` and lights were left on after a stop. + """ + try: + # Verify target exists + target_store.get_target(target_id) + count = await manager.turn_off_ha_light_target(target_id) + return {"status": "ok", "target_id": target_id, "entities": count} + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except RuntimeError as e: + raise HTTPException(status_code=409, detail=str(e)) + except Exception as e: + logger.error("Failed to turn off HA lights: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + # ===== HA LIGHT COLOR PREVIEW WEBSOCKET ===== @@ -377,6 +406,75 @@ async def ha_light_colors_ws( manager.remove_ha_light_ws_client(target_id, websocket) +# ===== Z2M LIGHT — MANUAL TURN OFF ===== + + +@router.post("/api/v1/output-targets/{target_id}/z2m-light/turn-off", tags=["Processing"]) +async def turn_off_z2m_light_target( + target_id: str, + _auth: AuthRequired, + target_store: OutputTargetStore = Depends(get_output_target_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Publish OFF to all Z2M bulbs mapped by the target. + + Works regardless of whether the target's processor is running. Useful + when ``stop_action`` is ``"none"`` and bulbs were left on after a stop. + """ + try: + target_store.get_target(target_id) + count = await manager.turn_off_z2m_light_target(target_id) + return {"status": "ok", "target_id": target_id, "entities": count} + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except RuntimeError as e: + raise HTTPException(status_code=409, detail=str(e)) + except Exception as e: + logger.error("Failed to turn off Z2M lights: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + +# ===== Z2M LIGHT COLOR PREVIEW WEBSOCKET ===== + + +@router.websocket("/api/v1/output-targets/{target_id}/z2m-light/ws") +async def z2m_light_colors_ws( + websocket: WebSocket, + target_id: str, +): + """WebSocket for live Z2M bulb colour preview. + + Streams: {"type":"colors_update","colors":{friendly_name:{r,g,b,hex},...}} + at the target's update_rate. Auth via first-message handshake. + """ + from ledgrab.api.auth import accept_and_authenticate_ws + + if await accept_and_authenticate_ws(websocket) is None: + return + + manager: ProcessorManager = get_processor_manager() + + try: + proc = manager._processors.get(target_id) + if not proc or not proc.is_running: + await websocket.close(code=4003, reason="Target not running") + return + except Exception as e: + await websocket.close(code=4004, reason=str(e)) + return + + try: + manager.add_z2m_light_ws_client(target_id, websocket) + while True: + await websocket.receive_text() + except WebSocketDisconnect: + pass + except (RuntimeError, ConnectionError) as e: + logger.debug("ws closed in z2m-light client: %s", e) + finally: + manager.remove_z2m_light_ws_client(target_id, websocket) + + # ===== LED PREVIEW WEBSOCKET ===== diff --git a/server/src/ledgrab/api/routes/system.py b/server/src/ledgrab/api/routes/system.py index b94876f..1a55397 100644 --- a/server/src/ledgrab/api/routes/system.py +++ b/server/src/ledgrab/api/routes/system.py @@ -25,6 +25,7 @@ from ledgrab.api.dependencies import ( get_device_store, get_ha_manager, get_ha_store, + get_mqtt_manager, get_output_target_store, get_picture_source_store, get_pp_template_store, @@ -380,22 +381,20 @@ async def get_integrations_status( _: AuthRequired, ha_store=Depends(get_ha_store), ha_manager=Depends(get_ha_manager), + mqtt_manager=Depends(get_mqtt_manager), ): """Return connection status for external integrations (MQTT, Home Assistant). - Used by the dashboard to show connectivity indicators. + Used by the dashboard to show connectivity indicators. MQTT is reported + per-source since the multi-broker refactor — no more global "MQTT + enabled" flag. """ - from ledgrab.core.devices.mqtt_client import get_mqtt_service - - # MQTT status - mqtt_service = get_mqtt_service() - mqtt_config = get_config().mqtt + # MQTT status — one entry per configured source + mqtt_items = mqtt_manager.get_all_sources_status() mqtt_status = { - "enabled": mqtt_config.enabled, - "connected": mqtt_service.is_connected if mqtt_service else False, - "broker": ( - f"{mqtt_config.broker_host}:{mqtt_config.broker_port}" if mqtt_config.enabled else None - ), + "sources": mqtt_items, + "total": len(mqtt_items), + "connected": sum(1 for s in mqtt_items if s.get("connected")), } # Home Assistant status diff --git a/server/src/ledgrab/api/schemas/output_targets.py b/server/src/ledgrab/api/schemas/output_targets.py index 234789d..8049b50 100644 --- a/server/src/ledgrab/api/schemas/output_targets.py +++ b/server/src/ledgrab/api/schemas/output_targets.py @@ -43,6 +43,20 @@ class HALightMappingSchema(BaseModel): ) +class Z2MLightMappingSchema(BaseModel): + """Maps an LED range to one Zigbee2MQTT bulb (by friendly name).""" + + friendly_name: str = Field( + description="Z2M friendly_name (e.g. 'living_room_bulb_1')", + min_length=1, + ) + led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)") + led_end: int = Field(default=-1, description="End LED index (-1 = last)") + brightness_scale: Optional[BindableFloatInput] = Field( + default=1.0, description="Brightness multiplier (bindable)" + ) + + # ===================================================================== # Response schemas (per-type, discriminated union) # ===================================================================== @@ -119,10 +133,56 @@ class HALightOutputTargetResponse(_OutputTargetResponseBase): ) +class Z2MLightOutputTargetResponse(_OutputTargetResponseBase): + target_type: Literal["z2m_light"] = "z2m_light" + mqtt_source_id: str = Field( + default="", + description="MQTT source (broker) the target publishes to. Empty = unconfigured.", + ) + source_kind: Literal["css", "color_vs"] = Field( + default="css", + description="Colour source kind: 'css' (per-mapping LED segments) or " + "'color_vs' (single colour value source applied to all bulbs).", + ) + color_strip_source_id: str = Field( + default="", description="Color strip source ID (used when source_kind='css')" + ) + color_value_source_id: str = Field( + default="", + description="Colour value source ID (used when source_kind='color_vs').", + ) + brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)") + z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field( + None, description="LED-to-bulb mappings (by Z2M friendly_name)" + ) + base_topic: str = Field( + default="zigbee2mqtt", + description="Z2M MQTT base topic prefix (override if your Z2M instance is non-default).", + ) + update_rate: Optional[BindableFloatInput] = Field( + None, description="Publish rate Hz (bindable; 0.5-10)" + ) + transition: Optional[BindableFloatInput] = Field( + None, description="Z2M transition seconds (bindable)" + ) + color_tolerance: Optional[BindableFloatInput] = Field( + None, description="RGB delta tolerance (bindable)" + ) + min_brightness_threshold: Optional[BindableFloatInput] = Field( + default=0, description="Min brightness threshold (bindable, 0=disabled)" + ) + stop_action: Literal["none", "turn_off"] = Field( + default="none", + description="What to do with mapped bulbs when the target stops: " + "'none' (leave as-is) or 'turn_off'.", + ) + + OutputTargetResponse = Annotated[ Union[ Annotated[LedOutputTargetResponse, Tag("led")], Annotated[HALightOutputTargetResponse, Tag("ha_light")], + Annotated[Z2MLightOutputTargetResponse, Tag("z2m_light")], ], Discriminator("target_type"), ] @@ -222,10 +282,58 @@ class HALightOutputTargetCreate(_OutputTargetCreateBase): ) +class Z2MLightOutputTargetCreate(_OutputTargetCreateBase): + target_type: Literal["z2m_light"] = "z2m_light" + mqtt_source_id: str = Field( + default="", + description="MQTT source (broker) the target publishes to. Required to start.", + ) + source_kind: Literal["css", "color_vs"] = Field( + default="css", + description="Colour source kind: 'css' or 'color_vs'.", + ) + color_strip_source_id: str = Field( + default="", description="Color strip source ID (used when source_kind='css')" + ) + color_value_source_id: str = Field( + default="", + description="Colour value source ID (used when source_kind='color_vs').", + ) + brightness: Optional[BindableFloatInput] = Field( + default=1.0, description="Brightness (bindable)" + ) + z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field( + None, description="LED-to-bulb mappings (by Z2M friendly_name)" + ) + base_topic: str = Field( + default="zigbee2mqtt", + max_length=128, + description="Z2M MQTT base topic prefix.", + ) + update_rate: Optional[BindableFloatInput] = Field( + default=5.0, description="Publish rate in Hz (bindable; 0.5-10)" + ) + transition: Optional[BindableFloatInput] = Field( + default=0.3, description="Z2M transition seconds (bindable)" + ) + color_tolerance: Optional[BindableFloatInput] = Field( + default=5, description="RGB delta tolerance (bindable)" + ) + min_brightness_threshold: Optional[BindableFloatInput] = Field( + default=0, + description="Min brightness threshold (bindable, 0=disabled); below this -> off", + ) + stop_action: Literal["none", "turn_off"] = Field( + default="none", + description="Finalization on stop: 'none' or 'turn_off'.", + ) + + OutputTargetCreate = Annotated[ Union[ Annotated[LedOutputTargetCreate, Tag("led")], Annotated[HALightOutputTargetCreate, Tag("ha_light")], + Annotated[Z2MLightOutputTargetCreate, Tag("z2m_light")], ], Discriminator("target_type"), ] @@ -309,10 +417,48 @@ class HALightOutputTargetUpdate(_OutputTargetUpdateBase): ) +class Z2MLightOutputTargetUpdate(_OutputTargetUpdateBase): + target_type: Literal["z2m_light"] = "z2m_light" + mqtt_source_id: Optional[str] = Field( + None, + description="MQTT source (broker) id. Empty string clears the binding.", + ) + source_kind: Optional[Literal["css", "color_vs"]] = Field( + None, description="Colour source kind: 'css' or 'color_vs'." + ) + color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") + color_value_source_id: Optional[str] = Field( + None, description="Colour value source ID (used when source_kind='color_vs')." + ) + brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)") + z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field( + None, description="LED-to-bulb mappings (by Z2M friendly_name)" + ) + base_topic: Optional[str] = Field( + None, max_length=128, description="Z2M MQTT base topic prefix." + ) + update_rate: Optional[BindableFloatInput] = Field( + None, description="Publish rate Hz (bindable; 0.5-10)" + ) + transition: Optional[BindableFloatInput] = Field( + None, description="Z2M transition seconds (bindable)" + ) + color_tolerance: Optional[BindableFloatInput] = Field( + None, description="RGB delta tolerance (bindable)" + ) + min_brightness_threshold: Optional[BindableFloatInput] = Field( + None, description="Min brightness threshold (bindable, 0=disabled)" + ) + stop_action: Optional[Literal["none", "turn_off"]] = Field( + None, description="Finalization on stop: 'none' or 'turn_off'." + ) + + OutputTargetUpdate = Annotated[ Union[ Annotated[LedOutputTargetUpdate, Tag("led")], Annotated[HALightOutputTargetUpdate, Tag("ha_light")], + Annotated[Z2MLightOutputTargetUpdate, Tag("z2m_light")], ], Discriminator("target_type"), ] diff --git a/server/src/ledgrab/config.py b/server/src/ledgrab/config.py index 0d59532..c42c171 100644 --- a/server/src/ledgrab/config.py +++ b/server/src/ledgrab/config.py @@ -83,52 +83,10 @@ class StorageConfig(BaseSettings): database_file: str = f"{_DEFAULT_DATA_DIR_STR}/ledgrab.db" -class MQTTConfig(BaseSettings): - """MQTT broker configuration. - - The ``password`` field accepts either plaintext or an ``ENC:v1:`` envelope - (see :mod:`ledgrab.utils.secret_box`). Use :func:`resolve_mqtt_password` - to obtain the plaintext value at runtime. - """ - - enabled: bool = False - broker_host: str = "localhost" - broker_port: int = 1883 - username: str = "" - password: str = "" - client_id: str = "ledgrab" - base_topic: str = "ledgrab" - - -def resolve_mqtt_password(config: "Config | None" = None) -> str: - """Return the plaintext MQTT password. - - Accepts either an ``ENC:v1:`` envelope or legacy plaintext. If - plaintext is detected, a warning is logged once per process start - so the user knows to migrate. - """ - from ledgrab.utils import get_logger, secret_box - - log = get_logger(__name__) - config = config or get_config() - pw = config.mqtt.password or "" - if not pw: - return "" - if secret_box.is_encrypted(pw): - try: - return secret_box.decrypt(pw) - except Exception as exc: - log.error("Failed to decrypt MQTT password: %s", exc) - return "" - # Plaintext — warn (once) - if not getattr(resolve_mqtt_password, "_warned", False): - log.warning( - "MQTT password in config.yaml is stored in plaintext. " - "Replace with an encrypted envelope (ENC:v1:...) — see " - "ledgrab.utils.secret_box.encrypt()." - ) - resolve_mqtt_password._warned = True # type: ignore[attr-defined] - return pw +# The legacy single-broker ``MQTTConfig`` block has been removed. Brokers +# are now first-class :class:`MQTTSource` entries managed through the UI; +# see :mod:`ledgrab.core.mqtt.legacy_migration` for the one-shot upgrade +# path that seeds an MQTTSource from any pre-existing ``mqtt:`` YAML block. class LoggingConfig(BaseSettings): @@ -158,6 +116,10 @@ class Config(BaseSettings): env_prefix="LEDGRAB_", env_nested_delimiter="__", case_sensitive=False, + # ``extra="ignore"`` lets pre-existing YAML files (with the now-removed + # ``mqtt:`` block, etc.) load without raising. The legacy MQTT block + # is handled by ``core.mqtt.legacy_migration`` on first startup. + extra="ignore", ) demo: bool = False @@ -166,7 +128,6 @@ class Config(BaseSettings): auth: AuthConfig = Field(default_factory=AuthConfig) storage: StorageConfig = Field(default_factory=StorageConfig) assets: AssetsConfig = Field(default_factory=AssetsConfig) - mqtt: MQTTConfig = Field(default_factory=MQTTConfig) logging: LoggingConfig = Field(default_factory=LoggingConfig) updates: UpdatesConfig = Field(default_factory=UpdatesConfig) diff --git a/server/src/ledgrab/core/automations/automation_engine.py b/server/src/ledgrab/core/automations/automation_engine.py index 3c4a2cf..fec6981 100644 --- a/server/src/ledgrab/core/automations/automation_engine.py +++ b/server/src/ledgrab/core/automations/automation_engine.py @@ -33,7 +33,6 @@ class AutomationEngine: automation_store: AutomationStore, processor_manager, poll_interval: float = 1.0, - mqtt_service=None, scene_preset_store=None, target_store=None, device_store=None, @@ -44,7 +43,6 @@ class AutomationEngine: self._manager = processor_manager self._poll_interval = poll_interval self._detector = PlatformDetector() - self._mqtt_service = mqtt_service self._mqtt_manager = mqtt_manager self._scene_preset_store = scene_preset_store self._target_store = target_store @@ -393,20 +391,14 @@ class AutomationEngine: return display_state == rule.state def _evaluate_mqtt(self, rule: MQTTRule) -> bool: - value = None - # Try entity-based manager first (new model) - if self._mqtt_manager is not None and rule.mqtt_source_id: - runtime = self._mqtt_manager.get_runtime(rule.mqtt_source_id) - if runtime and runtime.is_connected: - value = runtime.get_last_value(rule.topic) - elif self._mqtt_manager is not None: - # No source specified — try first available runtime - runtime = self._mqtt_manager.get_first_runtime() - if runtime: - value = runtime.get_last_value(rule.topic) - # Fallback to legacy global service - if value is None and self._mqtt_service is not None and self._mqtt_service.is_connected: - value = self._mqtt_service.get_last_value(rule.topic) + # Multi-broker model: the rule references a specific MQTTSource. + # Rules without one are no-ops (UI should enforce a source on save). + if self._mqtt_manager is None or not rule.mqtt_source_id: + return False + runtime = self._mqtt_manager.get_runtime(rule.mqtt_source_id) + if runtime is None or not runtime.is_connected: + return False + value = runtime.get_last_value(rule.topic) if value is None: return False matchers = { diff --git a/server/src/ledgrab/core/devices/device_config.py b/server/src/ledgrab/core/devices/device_config.py index 55c8936..ff2d65a 100644 --- a/server/src/ledgrab/core/devices/device_config.py +++ b/server/src/ledgrab/core/devices/device_config.py @@ -115,6 +115,7 @@ class DemoConfig(BaseDeviceConfig): @dataclass(frozen=True) class MQTTConfig(BaseDeviceConfig): device_type: Literal["mqtt"] = "mqtt" + mqtt_source_id: str = "" # references an MQTTSource (multi-broker) @dataclass(frozen=True) diff --git a/server/src/ledgrab/core/devices/led_client.py b/server/src/ledgrab/core/devices/led_client.py index 3fd2805..80c2106 100644 --- a/server/src/ledgrab/core/devices/led_client.py +++ b/server/src/ledgrab/core/devices/led_client.py @@ -17,6 +17,7 @@ class ProviderDeps: """Runtime dependencies injected into every provider.create_client() call.""" device_store: Optional["DeviceStore"] = None + mqtt_manager: Optional[object] = None # MQTTManager (avoid circular import) @dataclass diff --git a/server/src/ledgrab/core/devices/mqtt_client.py b/server/src/ledgrab/core/devices/mqtt_client.py index 794a4e5..119939d 100644 --- a/server/src/ledgrab/core/devices/mqtt_client.py +++ b/server/src/ledgrab/core/devices/mqtt_client.py @@ -1,7 +1,12 @@ -"""MQTT LED client — publishes pixel data to an MQTT topic.""" +"""MQTT LED client — publishes pixel data to an MQTT topic on a configured broker. + +The client acquires a per-source runtime from :class:`MQTTManager` at +``connect()`` time and releases it at ``close()``. Every device references +an ``mqtt_source_id`` so different devices can talk to different brokers. +""" import json -from typing import List, Tuple, Union +from typing import List, Optional, Tuple, Union import numpy as np @@ -10,23 +15,11 @@ from ledgrab.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) + Format: mqtt://topic/path (broker connection is per-source, not in URL) """ if url.startswith("mqtt://"): return url[7:] @@ -34,36 +27,64 @@ def parse_mqtt_url(url: str) -> str: class MQTTLEDClient(LEDClient): - """Publishes JSON pixel data to an MQTT topic via the shared service.""" + """Publishes JSON pixel data to an MQTT topic via an MQTTManager runtime.""" - def __init__(self, url: str, led_count: int = 0, **kwargs): + def __init__( + self, + url: str, + led_count: int = 0, + *, + mqtt_manager=None, + mqtt_source_id: str = "", + **kwargs, + ): self._topic = parse_mqtt_url(url) self._led_count = led_count + self._mqtt_manager = mqtt_manager + self._mqtt_source_id = mqtt_source_id + self._runtime = None 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") + if self._mqtt_manager is None: + raise ConnectionError("MQTT manager not available") + if not self._mqtt_source_id: + raise ConnectionError("Device has no mqtt_source_id configured") + try: + self._runtime = await self._mqtt_manager.acquire(self._mqtt_source_id) + except Exception as e: + raise ConnectionError(f"Failed to acquire MQTT runtime: {e}") from e + if not self._runtime.is_connected: + # Runtime exists but the broker hasn't established the TCP + # connection yet — leave the LED client in a "queued" state so + # publishes get buffered (MQTTRuntime.publish queues on + # disconnect). The runtime will drain when connection is made. + logger.info( + "MQTT LED client %s: runtime acquired but broker not yet connected", + self._mqtt_source_id, + ) self._connected = True return True async def close(self) -> None: + if self._runtime is not None and self._mqtt_manager is not None: + try: + await self._mqtt_manager.release(self._mqtt_source_id) + except Exception as e: + logger.warning("Failed to release MQTT runtime %s: %s", self._mqtt_source_id, e) + self._runtime = None self._connected = False @property def is_connected(self) -> bool: - return self._connected and _mqtt_service is not None and _mqtt_service.is_connected + return self._connected and self._runtime is not None and self._runtime.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: + if self._runtime is None: return False if isinstance(pixels, np.ndarray): @@ -79,7 +100,7 @@ class MQTTLEDClient(LEDClient): } ) - await svc.publish(self._topic, payload, retain=False, qos=0) + await self._runtime.publish(self._topic, payload, retain=False, qos=0) return True @classmethod @@ -88,16 +109,22 @@ class MQTTLEDClient(LEDClient): url: str, http_client, prev_health=None, + *, + mqtt_manager=None, + mqtt_source_id: Optional[str] = None, ) -> DeviceHealth: from datetime import datetime, timezone - svc = _mqtt_service - if svc is None or not svc.is_enabled: + if mqtt_manager is None or not mqtt_source_id: return DeviceHealth( - online=False, error="MQTT disabled", last_checked=datetime.now(timezone.utc) + online=False, + error="MQTT source not configured", + last_checked=datetime.now(timezone.utc), ) + runtime = mqtt_manager.get_runtime(mqtt_source_id) + connected = bool(runtime and runtime.is_connected) return DeviceHealth( - online=svc.is_connected, + online=connected, last_checked=datetime.now(timezone.utc), - error=None if svc.is_connected else "MQTT broker disconnected", + error=None if connected else "MQTT broker disconnected", ) diff --git a/server/src/ledgrab/core/devices/mqtt_provider.py b/server/src/ledgrab/core/devices/mqtt_provider.py index 234af78..0eadd87 100644 --- a/server/src/ledgrab/core/devices/mqtt_provider.py +++ b/server/src/ledgrab/core/devices/mqtt_provider.py @@ -38,6 +38,8 @@ class MQTTDeviceProvider(LEDDeviceProvider): return MQTTLEDClient( config.device_url, led_count=config.led_count, + mqtt_manager=deps.mqtt_manager, + mqtt_source_id=config.mqtt_source_id, ) async def check_health( @@ -46,7 +48,17 @@ class MQTTDeviceProvider(LEDDeviceProvider): http_client, prev_health=None, ) -> DeviceHealth: - return await MQTTLEDClient.check_health(url, http_client, prev_health) + # The provider-level check doesn't know which source the device + # references; the runtime check is best-effort. The healthier path + # is to wire mqtt_manager into the health monitor — for now we just + # return "unknown" so the dashboard doesn't show a stale "online". + from datetime import datetime, timezone + + return DeviceHealth( + online=False, + error="MQTT health requires mqtt_source_id (per-device runtime check)", + last_checked=datetime.now(timezone.utc), + ) async def validate_device(self, url: str) -> dict: """Validate MQTT device URL (topic path).""" diff --git a/server/src/ledgrab/core/mqtt/legacy_migration.py b/server/src/ledgrab/core/mqtt/legacy_migration.py new file mode 100644 index 0000000..a506644 --- /dev/null +++ b/server/src/ledgrab/core/mqtt/legacy_migration.py @@ -0,0 +1,123 @@ +"""One-shot migration: legacy global ``MQTTConfig`` (YAML/env) → first ``MQTTSource``. + +Pre-multi-broker, LedGrab had a single MQTT broker configured under +``mqtt:`` in ``config.yaml`` (and ``LEDGRAB_MQTT__*`` env vars). After the +multi-broker refactor those settings are no longer read at runtime — every +consumer references an ``MQTTSource`` id. + +To avoid silently dropping the legacy broker config when upgrading, this +migrator runs once at startup. If the source store is empty AND the +legacy YAML/env values look configured (broker_host set or ``enabled=true``), +it creates a single :class:`MQTTSource` named "Default Broker" from those +values. After the first run nothing happens — the store is the source of +truth. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + +import yaml + +from ledgrab.storage.mqtt_source_store import MQTTSourceStore +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + + +def _legacy_mqtt_from_env() -> Optional[dict]: + """Read legacy ``LEDGRAB_MQTT__*`` env vars (pydantic-settings convention).""" + enabled = os.environ.get("LEDGRAB_MQTT__ENABLED", "").strip().lower() + host = os.environ.get("LEDGRAB_MQTT__BROKER_HOST", "").strip() + if not host and enabled not in ("true", "1", "yes"): + return None + return { + "enabled": enabled in ("true", "1", "yes"), + "broker_host": host or "localhost", + "broker_port": int(os.environ.get("LEDGRAB_MQTT__BROKER_PORT", "1883") or "1883"), + "username": os.environ.get("LEDGRAB_MQTT__USERNAME", "") or "", + "password": os.environ.get("LEDGRAB_MQTT__PASSWORD", "") or "", + "client_id": os.environ.get("LEDGRAB_MQTT__CLIENT_ID", "ledgrab") or "ledgrab", + "base_topic": os.environ.get("LEDGRAB_MQTT__BASE_TOPIC", "ledgrab") or "ledgrab", + } + + +def _candidate_config_paths() -> list[Path]: + """Yield the YAML files that might hold the legacy ``mqtt:`` block.""" + paths: list[Path] = [] + override = os.environ.get("LEDGRAB_CONFIG_PATH") + if override: + paths.append(Path(override)) + if os.environ.get("LEDGRAB_DEMO", "").lower() in ("true", "1", "yes"): + paths.append(Path("config/demo_config.yaml")) + paths.append(Path("config/default_config.yaml")) + return paths + + +def _legacy_mqtt_from_yaml() -> Optional[dict]: + """Read legacy ``mqtt:`` block from the platform config.yaml.""" + for path in _candidate_config_paths(): + if not path.is_file(): + continue + try: + with open(path, "r", encoding="utf-8") as f: + doc = yaml.safe_load(f) or {} + except Exception as e: + logger.debug("Could not read %s for MQTT migration: %s", path, e) + continue + block = doc.get("mqtt") if isinstance(doc, dict) else None + if isinstance(block, dict): + host = (block.get("broker_host") or "").strip() + enabled = bool(block.get("enabled")) + if host or enabled: + return { + "enabled": enabled, + "broker_host": host or "localhost", + "broker_port": int(block.get("broker_port") or 1883), + "username": str(block.get("username") or ""), + "password": str(block.get("password") or ""), + "client_id": str(block.get("client_id") or "ledgrab"), + "base_topic": str(block.get("base_topic") or "ledgrab"), + } + return None + + +def migrate_legacy_mqtt_config(store: MQTTSourceStore) -> None: + """Seed a "Default Broker" :class:`MQTTSource` if the store is empty + and a legacy YAML/env MQTT config is detected. + + Idempotent: if the store already has any sources, this is a no-op. + Logs a deprecation warning when migration runs. + """ + if store.count() > 0: + return + + legacy = _legacy_mqtt_from_env() or _legacy_mqtt_from_yaml() + if legacy is None: + return + + try: + source = store.create_source( + name="Default Broker", + broker_host=legacy["broker_host"], + broker_port=legacy["broker_port"], + username=legacy["username"], + password=legacy["password"], + client_id=legacy["client_id"], + base_topic=legacy["base_topic"], + description="Migrated from legacy mqtt: config block", + ) + except Exception as e: + logger.error("Failed to migrate legacy MQTT config: %s", e) + return + + logger.warning( + "Migrated legacy MQTT config to source %s (%s:%d). " + "The 'mqtt:' YAML block and LEDGRAB_MQTT__* env vars are no longer " + "read; edit the broker in the UI under MQTT Sources from now on.", + source.id, + source.broker_host, + source.broker_port, + ) diff --git a/server/src/ledgrab/core/mqtt/mqtt_service.py b/server/src/ledgrab/core/mqtt/mqtt_service.py deleted file mode 100644 index de9d9fc..0000000 --- a/server/src/ledgrab/core/mqtt/mqtt_service.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Singleton async MQTT service — shared broker connection for all features.""" - -import asyncio -import json -from typing import Callable, Dict, Optional, Set - -import aiomqtt - -from ledgrab.config import MQTTConfig -from ledgrab.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 (automation 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: - logger.debug("MQTT background task cancelled") - 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: - logger.warning("MQTT publish queue full, dropping message for topic %s", topic) - 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 automation 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_automation_state(self, automation_id: str, action: str) -> None: - """Publish automation state change to MQTT.""" - topic = f"{self._config.base_topic}/automation/{automation_id}/state" - await self.publish(topic, json.dumps({"action": action}), retain=True) diff --git a/server/src/ledgrab/core/processing/device_test_mode.py b/server/src/ledgrab/core/processing/device_test_mode.py index bca7ec2..7d8796c 100644 --- a/server/src/ledgrab/core/processing/device_test_mode.py +++ b/server/src/ledgrab/core/processing/device_test_mode.py @@ -91,7 +91,10 @@ class DeviceTestModeMixin: led_count=ds.led_count, use_ddp=True, ) - deps = ProviderDeps(device_store=self._device_store) + deps = ProviderDeps( + device_store=self._device_store, + mqtt_manager=getattr(self, "_mqtt_manager", None), + ) client = create_led_client(config, deps=deps) await client.connect() self._idle_clients[device_id] = client diff --git a/server/src/ledgrab/core/processing/ha_light_target_processor.py b/server/src/ledgrab/core/processing/ha_light_target_processor.py index 83e251b..36efa2c 100644 --- a/server/src/ledgrab/core/processing/ha_light_target_processor.py +++ b/server/src/ledgrab/core/processing/ha_light_target_processor.py @@ -522,6 +522,59 @@ class HALightTargetProcessor(TargetProcessor): snap[eid] = state return snap + def _unique_mapped_entity_ids(self) -> List[str]: + """Return unique non-empty entity ids from configured mappings.""" + entity_ids: List[str] = [] + seen = set() + for mapping in self._light_mappings: + eid = mapping.entity_id + if eid and eid not in seen: + seen.add(eid) + entity_ids.append(eid) + return entity_ids + + async def turn_off_lights(self) -> int: + """Turn off every mapped HA light entity. + + Works regardless of whether the processor is currently running. + If the HA runtime isn't already acquired, temporarily acquires it + from the manager so the call can succeed without starting the + target. Returns the number of entities the turn_off was issued for. + """ + entity_ids = self._unique_mapped_entity_ids() + if not entity_ids: + return 0 + + # Use existing runtime when running; otherwise borrow one from the + # manager via acquire/release so we don't keep a connection open. + ha_manager = getattr(self._ctx, "ha_manager", None) + runtime = self._ha_runtime + borrowed = False + if runtime is None: + if ha_manager is None or not self._ha_source_id: + raise RuntimeError("HA runtime not available") + runtime = await ha_manager.acquire(self._ha_source_id) + borrowed = True + + try: + if not runtime.is_connected: + raise RuntimeError("HA not connected") + for eid in entity_ids: + await runtime.call_service( + domain="light", + service="turn_off", + service_data={}, + target={"entity_id": eid}, + ) + finally: + if borrowed and ha_manager is not None: + try: + await ha_manager.release(self._ha_source_id) + except Exception: + pass + + return len(entity_ids) + async def _apply_stop_action(self) -> None: """Run the configured finalization on stop.""" if self._stop_action == "none": @@ -533,15 +586,7 @@ class HALightTargetProcessor(TargetProcessor): ) return - # Unique entity ids (a target may map the same entity twice in theory) - entity_ids = [] - seen = set() - for mapping in self._light_mappings: - eid = mapping.entity_id - if eid and eid not in seen: - seen.add(eid) - entity_ids.append(eid) - + entity_ids = self._unique_mapped_entity_ids() if not entity_ids: return diff --git a/server/src/ledgrab/core/processing/processor_manager.py b/server/src/ledgrab/core/processing/processor_manager.py index 347a6a7..d73bec0 100644 --- a/server/src/ledgrab/core/processing/processor_manager.py +++ b/server/src/ledgrab/core/processing/processor_manager.py @@ -71,6 +71,7 @@ class ProcessorDependencies: weather_manager: Optional[WeatherManager] = None asset_store: Optional[AssetStore] = None ha_manager: Optional[Any] = None # HomeAssistantManager + mqtt_manager: Optional[Any] = None # MQTTManager game_event_bus: Optional[Any] = None # GameEventBus audio_processing_template_store: Optional[Any] = None # AudioProcessingTemplateStore @@ -175,6 +176,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) # Wire value stream manager into CSS stream manager for composite layer brightness self._color_strip_stream_manager._value_stream_manager = self._value_stream_manager self._ha_manager = deps.ha_manager + self._mqtt_manager = deps.mqtt_manager self._overlay_manager = OverlayManager() self._event_queues: List[asyncio.Queue] = [] self._metrics_history = MetricsHistory(self) @@ -223,6 +225,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) self._devices.get(did), "test_mode_active", False ), ha_manager=self._ha_manager, + mqtt_manager=self._mqtt_manager, ) # ===== EVENT SYSTEM (state change notifications) ===== @@ -465,6 +468,55 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) self._processors[target_id] = proc logger.info(f"Registered HA light target: {target_id}") + def add_z2m_light_target( + self, + target_id: str, + mqtt_source_id: str, + source_kind: str = "css", + color_strip_source_id: str = "", + color_value_source_id: str = "", + brightness=None, + light_mappings=None, + base_topic: str = "zigbee2mqtt", + update_rate: float = 5.0, + transition=None, + min_brightness_threshold: int = 0, + color_tolerance: int = 5, + stop_action: str = "none", + ) -> None: + """Register a Zigbee2MQTT light target processor. + + ``mqtt_source_id`` references an entry in the MQTTSource store. The + processor will acquire/release that broker's runtime via the manager. + """ + if target_id in self._processors: + raise ValueError(f"Z2M light target {target_id} already registered") + if not mqtt_source_id: + raise ValueError("mqtt_source_id is required for Z2M light targets") + + from ledgrab.core.processing.z2m_light_target_processor import ( + Z2MLightTargetProcessor, + ) + + proc = Z2MLightTargetProcessor( + target_id=target_id, + mqtt_source_id=mqtt_source_id, + source_kind=source_kind, + color_strip_source_id=color_strip_source_id, + color_value_source_id=color_value_source_id, + brightness=brightness, + light_mappings=light_mappings or [], + base_topic=base_topic, + update_rate=update_rate, + transition=transition, + min_brightness_threshold=min_brightness_threshold, + color_tolerance=color_tolerance, + stop_action=stop_action, + ctx=self._build_context(), + ) + self._processors[target_id] = proc + logger.info(f"Registered Z2M light target: {target_id} -> mqtt[{mqtt_source_id}]") + def remove_target(self, target_id: str): """Unregister a target (any type).""" if target_id not in self._processors: @@ -755,6 +807,38 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) def is_css_overlay_active(self, css_id: str) -> bool: return self._overlay_manager.is_running(css_id) + # ===== HA LIGHT — MANUAL TURN OFF ===== + + async def turn_off_ha_light_target(self, target_id: str) -> int: + """Turn off all HA light entities mapped by the given target. + + Works whether or not the target's processor is running. Returns the + number of entities the turn_off was issued for. + """ + from ledgrab.core.processing.ha_light_target_processor import HALightTargetProcessor + + proc = self._get_processor(target_id) + if not isinstance(proc, HALightTargetProcessor): + raise ValueError(f"Target {target_id} is not an HA light target") + return await proc.turn_off_lights() + + # ===== Z2M LIGHT — MANUAL TURN OFF ===== + + async def turn_off_z2m_light_target(self, target_id: str) -> int: + """Publish OFF to all Z2M bulbs mapped by the given target. + + Works whether or not the target's processor is running. Returns the + number of bulbs the turn-off was issued for. + """ + from ledgrab.core.processing.z2m_light_target_processor import ( + Z2MLightTargetProcessor, + ) + + proc = self._get_processor(target_id) + if not isinstance(proc, Z2MLightTargetProcessor): + raise ValueError(f"Target {target_id} is not a Z2M light target") + return await proc.turn_off_lights() + # ===== WEBSOCKET (delegates to processor) ===== def add_ha_light_ws_client(self, target_id: str, ws) -> None: @@ -766,6 +850,15 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) if proc: proc.remove_ws_client(ws) + def add_z2m_light_ws_client(self, target_id: str, ws) -> None: + proc = self._get_processor(target_id) + proc.add_ws_client(ws) + + def remove_z2m_light_ws_client(self, target_id: str, ws) -> None: + proc = self._processors.get(target_id) + if proc: + proc.remove_ws_client(ws) + def add_led_preview_client(self, target_id: str, ws) -> None: proc = self._get_processor(target_id) proc.add_led_preview_client(ws) diff --git a/server/src/ledgrab/core/processing/target_processor.py b/server/src/ledgrab/core/processing/target_processor.py index 789ad09..ecb6602 100644 --- a/server/src/ledgrab/core/processing/target_processor.py +++ b/server/src/ledgrab/core/processing/target_processor.py @@ -97,6 +97,7 @@ class TargetContext: fire_event: Callable[[dict], None] = lambda e: None is_test_mode_active: Callable[[str], bool] = lambda _: False ha_manager: Optional[Any] = None # HomeAssistantManager (avoid circular import) + mqtt_manager: Optional[Any] = None # MQTTManager (avoid circular import) # --------------------------------------------------------------------------- diff --git a/server/src/ledgrab/core/processing/wled_target_processor.py b/server/src/ledgrab/core/processing/wled_target_processor.py index 30a3886..c3b5f41 100644 --- a/server/src/ledgrab/core/processing/wled_target_processor.py +++ b/server/src/ledgrab/core/processing/wled_target_processor.py @@ -136,7 +136,10 @@ class WledTargetProcessor(TargetProcessor): self._device_config = config # Connect to LED device - deps = ProviderDeps(device_store=self._ctx.device_store) + deps = ProviderDeps( + device_store=self._ctx.device_store, + mqtt_manager=getattr(self._ctx, "mqtt_manager", None), + ) try: self._led_client = create_led_client(config, deps=deps) await self._led_client.connect() diff --git a/server/src/ledgrab/core/processing/z2m_light_target_processor.py b/server/src/ledgrab/core/processing/z2m_light_target_processor.py new file mode 100644 index 0000000..55012af --- /dev/null +++ b/server/src/ledgrab/core/processing/z2m_light_target_processor.py @@ -0,0 +1,599 @@ +"""Zigbee2MQTT light target processor — publishes RGB+brightness directly to Z2M topics. + +Reads from a ColorStripStream or a colour-returning ValueStream, averages LED segments +to single RGB values, and publishes ``{"color":{"r","g","b"}, "brightness", "transition"}`` +payloads to ``//set`` on the shared MQTT broker. + +Bypasses Home Assistant — bulbs must already be paired with the Z2M coordinator. +""" + +import asyncio +import json +import time +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np + +from ledgrab.core.processing.target_processor import TargetContext, TargetProcessor +from ledgrab.storage.z2m_light_output_target import ( + DEFAULT_Z2M_BASE_TOPIC, + Z2MLightMapping, +) +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + + +class Z2MLightTargetProcessor(TargetProcessor): + """Streams averaged LED colors to Zigbee2MQTT bulbs via MQTT.""" + + def __init__( + self, + target_id: str, + mqtt_source_id: str, + source_kind: str = "css", + color_strip_source_id: str = "", + color_value_source_id: str = "", + brightness=None, + light_mappings: Optional[List[Z2MLightMapping]] = None, + base_topic: str = DEFAULT_Z2M_BASE_TOPIC, + update_rate: float = 5.0, + transition=None, + min_brightness_threshold: int = 0, + color_tolerance: int = 5, + stop_action: str = "none", + ctx: Optional[TargetContext] = None, + ): + from ledgrab.storage.bindable import BindableFloat, bfloat + + super().__init__(target_id, ctx) + self._mqtt_source_id = mqtt_source_id + self._source_kind = source_kind if source_kind in ("css", "color_vs") else "css" + self._css_id = color_strip_source_id + self._color_vs_id = color_value_source_id + if brightness is not None and isinstance(brightness, BindableFloat): + self._brightness = brightness + else: + self._brightness = BindableFloat(1.0) + if transition is not None and isinstance(transition, BindableFloat): + self._transition = transition + else: + self._transition = BindableFloat(float(transition) if transition is not None else 0.3) + self._light_mappings = light_mappings or [] + self._base_topic = (base_topic or "").strip() or DEFAULT_Z2M_BASE_TOPIC + # Z2M ceiling — 10 Hz is the realistic Zigbee-mesh upper bound per bulb. + self._update_rate = max(0.5, min(10.0, bfloat(update_rate, 5.0))) + self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0)) + self._color_tolerance = int(bfloat(color_tolerance, 5.0)) + self._stop_action = stop_action if stop_action in ("none", "turn_off") else "none" + + # Runtime state + self._css_stream = None + self._color_stream = None + self._value_stream = None # brightness VS stream + self._previous_colors: Dict[str, Tuple[int, int, int]] = {} + self._previous_on: Dict[str, bool] = {} + self._latest_entity_colors: Dict[str, Tuple[int, int, int]] = {} + self._ws_clients: List[Any] = [] + self._start_time: Optional[float] = None + # MQTT runtime acquired from MQTTManager at start(); released at stop(). + self._mqtt_runtime = None + # Track whether we hold an outstanding acquire() so stop() knows to release. + self._mqtt_acquired_id: Optional[str] = None + + @property + def device_id(self) -> Optional[str]: + return None # Z2M targets don't use device providers + + # ─────────── Lifecycle ─────────── + + async def start(self) -> None: + if self._is_running: + return + + if self._source_kind == "color_vs": + if self._color_vs_id and self._ctx.value_stream_manager: + try: + self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id) + except Exception as e: + logger.warning( + f"Z2M light {self._target_id}: failed to acquire color VS stream: {e}" + ) + else: + if self._css_id and self._ctx.color_strip_stream_manager: + try: + self._css_stream = self._ctx.color_strip_stream_manager.acquire( + self._css_id, self._target_id + ) + except Exception as e: + logger.warning( + f"Z2M light {self._target_id}: failed to acquire CSS stream: {e}" + ) + + if self._brightness.source_id and self._ctx.value_stream_manager: + try: + self._value_stream = self._ctx.value_stream_manager.acquire( + self._brightness.source_id + ) + except Exception as e: + logger.warning(f"Z2M light {self._target_id}: failed to acquire brightness VS: {e}") + self._value_stream = None + + # Acquire the MQTT runtime for our broker source. If the manager is + # missing or the source can't be resolved, the runtime stays None and + # publishes are dropped — the loop guards on it. + mqtt_manager = getattr(self._ctx, "mqtt_manager", None) + if mqtt_manager is None: + logger.warning( + f"Z2M light {self._target_id}: no MQTT manager in context — frames will be dropped" + ) + elif not self._mqtt_source_id: + logger.warning( + f"Z2M light {self._target_id}: no mqtt_source_id configured — frames will be dropped" + ) + else: + try: + self._mqtt_runtime = await mqtt_manager.acquire(self._mqtt_source_id) + self._mqtt_acquired_id = self._mqtt_source_id + except Exception as e: + logger.warning( + f"Z2M light {self._target_id}: failed to acquire MQTT runtime " + f"for source {self._mqtt_source_id}: {e}" + ) + + self._is_running = True + self._start_time = time.monotonic() + self._task = asyncio.create_task(self._processing_loop()) + logger.info(f"Z2M light target started: {self._target_id} -> mqtt[{self._mqtt_source_id}]") + + async def stop(self) -> None: + self._is_running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + try: + await self._apply_stop_action() + except Exception as e: + logger.warning( + f"Z2M light {self._target_id}: stop_action '{self._stop_action}' failed: {e}" + ) + + if self._css_stream and self._ctx.color_strip_stream_manager: + try: + self._ctx.color_strip_stream_manager.release(self._css_id, self._target_id) + except Exception: + pass + self._css_stream = None + + if self._color_stream is not None and self._ctx.value_stream_manager: + try: + self._ctx.value_stream_manager.release(self._color_vs_id) + except Exception: + pass + self._color_stream = None + + if self._value_stream is not None and self._ctx.value_stream_manager: + try: + self._ctx.value_stream_manager.release(self._brightness.source_id) + except Exception: + pass + self._value_stream = None + + # Release the MQTT runtime so the broker connection can be torn down + # if no other consumer holds it. + if self._mqtt_acquired_id is not None: + mqtt_manager = getattr(self._ctx, "mqtt_manager", None) + if mqtt_manager is not None: + try: + await mqtt_manager.release(self._mqtt_acquired_id) + except Exception as e: + logger.warning( + f"Z2M light {self._target_id}: failed to release MQTT runtime: {e}" + ) + self._mqtt_acquired_id = None + self._mqtt_runtime = None + + self._previous_colors.clear() + self._previous_on.clear() + self._latest_entity_colors.clear() + self._ws_clients.clear() + logger.info(f"Z2M light target stopped: {self._target_id}") + + # ─────────── Settings ─────────── + + def update_settings(self, settings) -> None: + from ledgrab.storage.bindable import BindableFloat, bfloat + + if not isinstance(settings, dict): + return + if "update_rate" in settings: + self._update_rate = max(0.5, min(10.0, bfloat(settings["update_rate"], 5.0))) + if "transition" in settings: + t = settings["transition"] + self._transition = ( + t if isinstance(t, BindableFloat) else self._transition.apply_update(t) + ) + if "brightness" in settings: + b = settings["brightness"] + self._brightness = ( + b if isinstance(b, BindableFloat) else self._brightness.apply_update(b) + ) + if "base_topic" in settings: + bt = (settings["base_topic"] or "").strip() + self._base_topic = bt or DEFAULT_Z2M_BASE_TOPIC + if "mqtt_source_id" in settings: + # The broker swap itself is deferred to a stop/restart cycle + # (acquire/release happens at start()/stop() boundaries). We + # record the new id so the next start() uses the right runtime. + new_id = settings["mqtt_source_id"] or "" + if new_id != self._mqtt_source_id: + self._mqtt_source_id = new_id + if "min_brightness_threshold" in settings: + self._min_brightness_threshold = int(bfloat(settings["min_brightness_threshold"], 0.0)) + if "color_tolerance" in settings: + self._color_tolerance = int(bfloat(settings["color_tolerance"], 5.0)) + if "light_mappings" in settings: + self._light_mappings = settings["light_mappings"] + if "stop_action" in settings: + sa = settings["stop_action"] + if sa in ("none", "turn_off"): + self._stop_action = sa + + new_kind = settings.get("source_kind") + new_color_vs = settings.get("color_value_source_id") + kind_changed = new_kind in ("css", "color_vs") and new_kind != self._source_kind + color_vs_changed = new_color_vs is not None and new_color_vs != self._color_vs_id + if kind_changed or color_vs_changed: + self._swap_color_source( + new_kind if kind_changed else self._source_kind, + new_color_vs if new_color_vs is not None else self._color_vs_id, + ) + + def update_css_source(self, color_strip_source_id: str) -> None: + old_id = self._css_id + self._css_id = color_strip_source_id + if self._source_kind == "css" and self._is_running and self._ctx.color_strip_stream_manager: + try: + new_stream = self._ctx.color_strip_stream_manager.acquire( + color_strip_source_id, self._target_id + ) + old_stream = self._css_stream + self._css_stream = new_stream + if old_stream: + self._ctx.color_strip_stream_manager.release(old_id, self._target_id) + except Exception as e: + logger.warning(f"Z2M light {self._target_id}: CSS swap failed: {e}") + + def _swap_color_source(self, new_kind: str, new_color_vs_id: str) -> None: + if self._is_running: + if self._css_stream and self._ctx.color_strip_stream_manager: + try: + self._ctx.color_strip_stream_manager.release(self._css_id, self._target_id) + except Exception: + pass + self._css_stream = None + if self._color_stream is not None and self._ctx.value_stream_manager: + try: + self._ctx.value_stream_manager.release(self._color_vs_id) + except Exception: + pass + self._color_stream = None + + self._source_kind = new_kind + self._color_vs_id = new_color_vs_id + self._previous_colors.clear() + self._previous_on.clear() + + if not self._is_running: + return + + if self._source_kind == "color_vs": + if self._color_vs_id and self._ctx.value_stream_manager: + try: + self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id) + except Exception as e: + logger.warning(f"Z2M light {self._target_id}: failed to acquire color VS: {e}") + else: + if self._css_id and self._ctx.color_strip_stream_manager: + try: + self._css_stream = self._ctx.color_strip_stream_manager.acquire( + self._css_id, self._target_id + ) + except Exception as e: + logger.warning( + f"Z2M light {self._target_id}: failed to re-acquire CSS stream: {e}" + ) + + # ─────────── WebSocket clients ─────────── + + def add_ws_client(self, ws: Any) -> None: + self._ws_clients.append(ws) + + def remove_ws_client(self, ws: Any) -> None: + if ws in self._ws_clients: + self._ws_clients.remove(ws) + + def supports_websocket(self) -> bool: + return True + + # ─────────── State / metrics ─────────── + + def get_state(self) -> dict: + uptime = time.monotonic() - self._start_time if self._start_time and self._is_running else 0 + entity_colors = { + name: {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"} + for name, (r, g, b) in self._latest_entity_colors.items() + } + mqtt_connected = bool(self._mqtt_runtime and self._mqtt_runtime.is_connected) + return { + "target_id": self._target_id, + "processing": self._is_running, + "source_kind": self._source_kind, + "css_id": self._css_id, + "color_value_source_id": self._color_vs_id, + "mqtt_source_id": self._mqtt_source_id, + "is_running": self._is_running, + "mqtt_connected": mqtt_connected, + "base_topic": self._base_topic, + "light_count": len(self._light_mappings), + "update_rate": self._update_rate, + "fps_actual": self._update_rate if self._is_running else None, + "fps_target": self._update_rate, + "fps_capture": self._update_rate if self._is_running else None, + "uptime_seconds": uptime, + "entity_colors": entity_colors, + } + + def get_metrics(self) -> dict: + uptime = time.monotonic() - self._start_time if self._start_time and self._is_running else 0 + return { + "target_id": self._target_id, + "processing": self._is_running, + "fps_actual": self._update_rate if self._is_running else None, + "fps_target": self._update_rate, + "uptime_seconds": uptime, + "frames_processed": 0, + "errors_count": 0, + "last_error": None, + "last_update": None, + } + + # ─────────── Processing loop ─────────── + + async def _processing_loop(self) -> None: + interval = 1.0 / self._update_rate + while self._is_running: + try: + loop_start = time.monotonic() + + if self._mqtt_runtime is not None and self._mqtt_runtime.is_connected: + if self._source_kind == "color_vs" and self._color_stream is not None: + try: + color = self._color_stream.get_color() + except Exception: + color = None + if isinstance(color, (list, tuple)) and len(color) >= 3: + await self._update_lights_single_color( + int(color[0]), int(color[1]), int(color[2]) + ) + elif self._css_stream is not None: + colors = self._css_stream.get_latest_colors() + if colors is not None and len(colors) > 0: + await self._update_lights(colors) + + elapsed = time.monotonic() - loop_start + # Refresh interval each loop so update_rate changes take effect immediately. + interval = 1.0 / max(0.5, self._update_rate) + sleep_time = max(0.05, interval - elapsed) + await asyncio.sleep(sleep_time) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Z2M light {self._target_id} loop error: {e}") + await asyncio.sleep(1.0) + + def _read_brightness_multiplier(self) -> float: + if self._value_stream is None: + return 1.0 + try: + return float(self._value_stream.get_value()) + except Exception: + return 1.0 + + def _topic_for(self, friendly_name: str) -> str: + # Z2M topic convention: //set + return f"{self._base_topic}/{friendly_name}/set" + + async def _publish_payload(self, friendly_name: str, payload: dict) -> None: + if not friendly_name or self._mqtt_runtime is None: + return + try: + await self._mqtt_runtime.publish( + self._topic_for(friendly_name), + json.dumps(payload), + retain=False, + qos=0, + ) + except Exception as e: + logger.debug(f"Z2M light {self._target_id}: publish failed for {friendly_name}: {e}") + + async def _send_entity_color( + self, + mapping: Z2MLightMapping, + r: int, + g: int, + b: int, + vs_multiplier: float, + ) -> None: + """Apply tolerance/threshold gates and publish one Z2M command.""" + friendly = mapping.friendly_name + if not friendly: + return + + # Cache for WS preview (always, even if MQTT is skipped). + self._latest_entity_colors[friendly] = (r, g, b) + + bs = ( + mapping.brightness_scale.value + if hasattr(mapping.brightness_scale, "value") + else mapping.brightness_scale + ) + eff_scale = bs * vs_multiplier + # Z2M uses 0..254 for `brightness` on Zigbee bulbs. + brightness = max(0, min(254, int(max(r, g, b) * eff_scale))) + + should_be_on = ( + brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0 + ) + + prev_color = self._previous_colors.get(friendly) + was_on = self._previous_on.get(friendly, True) + + if should_be_on: + if prev_color is not None and was_on: + dr = abs(r - prev_color[0]) + dg = abs(g - prev_color[1]) + db = abs(b - prev_color[2]) + if max(dr, dg, db) < self._color_tolerance: + return # colour hasn't changed enough + + payload: Dict[str, Any] = { + "state": "ON", + "color": {"r": r, "g": g, "b": b}, + "brightness": brightness, + } + transition_val = self._transition.value + if transition_val > 0: + payload["transition"] = transition_val + + await self._publish_payload(friendly, payload) + self._previous_colors[friendly] = (r, g, b) + self._previous_on[friendly] = True + + elif was_on: + await self._publish_payload(friendly, {"state": "OFF"}) + self._previous_on[friendly] = False + self._previous_colors.pop(friendly, None) + + async def _update_lights(self, colors: np.ndarray) -> None: + led_count = len(colors) + vs_multiplier = self._read_brightness_multiplier() + + for mapping in self._light_mappings: + if not mapping.friendly_name: + continue + start = max(0, mapping.led_start) + end = mapping.led_end if mapping.led_end >= 0 else led_count + end = min(end, led_count) + if start >= end: + continue + segment = colors[start:end] + avg = segment.mean(axis=0).astype(int) + await self._send_entity_color( + mapping, int(avg[0]), int(avg[1]), int(avg[2]), vs_multiplier + ) + + if self._ws_clients and self._latest_entity_colors: + await self._broadcast_entity_colors() + + async def _update_lights_single_color(self, r: int, g: int, b: int) -> None: + vs_multiplier = self._read_brightness_multiplier() + for mapping in self._light_mappings: + if not mapping.friendly_name: + continue + await self._send_entity_color(mapping, r, g, b, vs_multiplier) + + if self._ws_clients and self._latest_entity_colors: + await self._broadcast_entity_colors() + + async def _broadcast_entity_colors(self) -> None: + colors_payload = { + name: {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"} + for name, (r, g, b) in self._latest_entity_colors.items() + } + message = json.dumps({"type": "colors_update", "colors": colors_payload}) + dead: List[Any] = [] + for ws in self._ws_clients: + try: + await ws.send_text(message) + except Exception: + dead.append(ws) + for ws in dead: + self._ws_clients.remove(ws) + + # ─────────── Stop-action finalization ─────────── + + def _unique_mapped_friendly_names(self) -> List[str]: + names: List[str] = [] + seen = set() + for m in self._light_mappings: + fn = m.friendly_name + if fn and fn not in seen: + seen.add(fn) + names.append(fn) + return names + + async def turn_off_lights(self) -> int: + """Publish ``{"state":"OFF"}`` to every mapped bulb. + + Works whether or not the processor is running. When the processor + isn't running, temporarily borrows the runtime from the MQTT manager + and releases it afterwards. Returns the number of bulbs the + turn-off was issued for. + """ + names = self._unique_mapped_friendly_names() + if not names: + return 0 + + mqtt_manager = getattr(self._ctx, "mqtt_manager", None) + if mqtt_manager is None: + raise RuntimeError("MQTT manager not available") + if not self._mqtt_source_id: + raise RuntimeError("Target has no MQTT source configured") + + # Use the already-acquired runtime when running; otherwise borrow one. + runtime = self._mqtt_runtime + borrowed = False + if runtime is None: + runtime = await mqtt_manager.acquire(self._mqtt_source_id) + borrowed = True + + try: + if not runtime.is_connected: + raise RuntimeError("MQTT broker not connected") + # Swap in the borrowed runtime so _publish_payload uses it. + prev_runtime = self._mqtt_runtime + self._mqtt_runtime = runtime + try: + for name in names: + await self._publish_payload(name, {"state": "OFF"}) + finally: + self._mqtt_runtime = prev_runtime + finally: + if borrowed: + try: + await mqtt_manager.release(self._mqtt_source_id) + except Exception as e: + logger.warning( + f"Z2M light {self._target_id}: failed to release borrowed runtime: {e}" + ) + return len(names) + + async def _apply_stop_action(self) -> None: + if self._stop_action == "none": + return + if self._mqtt_runtime is None or not self._mqtt_runtime.is_connected: + logger.info( + f"Z2M light {self._target_id}: skipping stop_action " + f"'{self._stop_action}' — MQTT not connected" + ) + return + if self._stop_action == "turn_off": + for name in self._unique_mapped_friendly_names(): + await self._publish_payload(name, {"state": "OFF"}) diff --git a/server/src/ledgrab/main.py b/server/src/ledgrab/main.py index 54bd8e3..be323e5 100644 --- a/server/src/ledgrab/main.py +++ b/server/src/ledgrab/main.py @@ -48,13 +48,11 @@ from ledgrab.storage.game_integration_store import GameIntegrationStore from ledgrab.core.game_integration.event_bus import GameEventBus import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters from ledgrab.core.game_integration.community_loader import register_community_adapters -from ledgrab.core.mqtt.mqtt_service import MQTTService from ledgrab.core.mqtt.mqtt_manager import MQTTManager from ledgrab.storage.mqtt_source_store import MQTTSourceStore from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore from ledgrab.storage.pattern_template_store import PatternTemplateStore import ledgrab.core.audio.filters # noqa: F401 — trigger audio filter auto-registration -from ledgrab.core.devices.mqtt_client import set_mqtt_service from ledgrab.core.backup.auto_backup import AutoBackupEngine from ledgrab.core.processing.os_notification_listener import OsNotificationListener from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher @@ -167,6 +165,7 @@ weather_manager = WeatherManager(weather_source_store) ha_store = HomeAssistantStore(db) ha_manager = HomeAssistantManager(ha_store) mqtt_source_store = MQTTSourceStore(db) +mqtt_manager = MQTTManager(mqtt_source_store) audio_processing_template_store = AudioProcessingTemplateStore(db) game_integration_store = GameIntegrationStore(db) pattern_template_store = PatternTemplateStore(db) @@ -189,6 +188,7 @@ processor_manager = ProcessorManager( weather_manager=weather_manager, asset_store=asset_store, ha_manager=ha_manager, + mqtt_manager=mqtt_manager, game_event_bus=game_event_bus, audio_processing_template_store=audio_processing_template_store, ) @@ -238,18 +238,19 @@ async def lifespan(app: FastAPI): client_labels = ", ".join(config.auth.api_keys.keys()) logger.info(f"Authorized clients: {client_labels}") - # Create MQTT service (shared broker connection — legacy, used by MQTTLEDClient) - mqtt_service = MQTTService(config.mqtt) - set_mqtt_service(mqtt_service) + # One-shot migration: legacy global ``mqtt:`` config block → first MQTTSource. + # No-op once the store has any entries. + try: + from ledgrab.core.mqtt.legacy_migration import migrate_legacy_mqtt_config - # Create MQTT manager (multi-source, ref-counted — new entity-based model) - mqtt_manager = MQTTManager(mqtt_source_store) + migrate_legacy_mqtt_config(mqtt_source_store) + except Exception as e: + logger.error("Legacy MQTT migration failed: %s", e) - # Create automation engine (needs processor_manager + mqtt + stores for scene activation) + # Create automation engine (needs processor_manager + MQTT manager + stores for scene activation) automation_engine = AutomationEngine( automation_store, processor_manager, - mqtt_service=mqtt_service, scene_preset_store=scene_preset_store, target_store=output_target_store, device_store=device_store, @@ -347,9 +348,6 @@ async def lifespan(app: FastAPI): # Start background health monitoring for all devices await processor_manager.start_health_monitoring() - # Start MQTT service (broker connection for output, triggers, state) - await mqtt_service.start() - # Start automation engine (evaluates conditions and activates scenes) await automation_engine.start() @@ -415,11 +413,11 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Error stopping OS notification listener: {e}") - # Stop all processing BEFORE tearing down ha_manager / mqtt_manager / - # mqtt_service. HA-light targets need a live HA runtime to apply their - # stop_action (turn_off / restore), and MQTT-output devices need a live - # MQTT broker connection to send restore frames. Shutting those down - # first silently turns "stop_targets" into a no-op for those targets. + # Stop all processing BEFORE tearing down ha_manager / mqtt_manager. + # HA-light targets need a live HA runtime to apply their stop_action + # (turn_off / restore), and MQTT-output targets need a live broker + # runtime to send restore frames. Shutting those down first silently + # turns "stop_targets" into a no-op for those targets. # # The shutdown_action setting controls whether per-device restore # frames are sent: "stop_targets" (default) runs the normal stop @@ -451,11 +449,6 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Error stopping MQTT manager: {e}") - try: - await mqtt_service.stop() - except Exception as e: - logger.error(f"Error stopping MQTT service: {e}") - # Independent services — order doesn't matter relative to processors. try: weather_manager.shutdown() diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index caa398f..c5f72ed 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -732,21 +732,198 @@ textarea:focus-visible { .ws-url-row .btn { padding: 4px 10px; min-width: 0; flex: 0 0 auto; } .endpoint-label { display: block; font-weight: 600; margin-bottom: 2px; opacity: 0.7; font-size: 0.8em; } -/* Scene target selector */ +/* Scene target selector — patch-bay channel slots, paired with the + .ds-section[data-ch="cyan"] panel in scene-preset-editor.html. + Each item reads like a numbered channel: index · icon plate · name + + type chip · remove. Slot indices are rendered via a CSS counter so + DOM reorders don't need JS renumbering. */ .scene-target-list { + --st-ch: var(--ch-cyan, var(--info-color, #00d8ff)); + counter-reset: st-slot; display: flex; flex-direction: column; gap: 4px; + padding: 0; } +.scene-target-list:empty::before { + content: attr(data-empty); + display: block; + padding: 14px 12px; + font-size: 0.78rem; + color: var(--lux-ink-dim, var(--text-secondary)); + border: 1px dashed color-mix(in srgb, var(--st-ch) 40%, var(--lux-line, var(--border-color))); + border-radius: var(--lux-r-md, 6px); + background: + repeating-linear-gradient(135deg, + color-mix(in srgb, var(--st-ch) 4%, transparent) 0 6px, + transparent 6px 12px); + text-align: center; + letter-spacing: 0.04em; +} + .scene-target-item { + counter-increment: st-slot; + position: relative; + display: grid; + grid-template-columns: 26px 32px 1fr auto; + align-items: center; + gap: 10px; + padding: 6px 8px 6px 6px; + border: 1px solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-md, 6px); + background: + linear-gradient(180deg, + color-mix(in srgb, var(--st-ch) 3%, var(--lux-bg-2, var(--bg-secondary))) 0%, + color-mix(in srgb, var(--lux-bg-1, var(--card-bg)) 70%, transparent) 100%); + font-size: 0.85rem; + transition: + border-color 0.15s ease, + background 0.15s ease, + box-shadow 0.15s ease; +} +.scene-target-item:hover { + border-color: color-mix(in srgb, var(--st-ch) 55%, var(--lux-line, var(--border-color))); + box-shadow: + inset 2px 0 0 color-mix(in srgb, var(--st-ch) 80%, transparent), + 0 1px 0 color-mix(in srgb, var(--st-ch) 14%, transparent); +} +.scene-target-item::before { + content: counter(st-slot, decimal-leading-zero); + grid-column: 1; + justify-self: center; + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.06em; + color: color-mix(in srgb, var(--st-ch) 75%, var(--lux-ink-dim, var(--text-secondary))); + opacity: 0.85; +} + +.scene-target-icon { + grid-column: 2; + width: 32px; + height: 32px; display: flex; align-items: center; - justify-content: space-between; - padding: 6px 10px; - border: 1px solid var(--border-color); - border-radius: var(--radius); - background: var(--bg-secondary); - font-size: 0.9rem; + justify-content: center; + color: var(--st-ch); + background: color-mix(in srgb, var(--st-ch) 9%, transparent); + border: 1px solid color-mix(in srgb, var(--st-ch) 22%, transparent); + border-radius: 5px; + flex-shrink: 0; +} +.scene-target-icon svg, +.scene-target-icon .icon { width: 18px; height: 18px; } + +.scene-target-id { + grid-column: 3; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.scene-target-name { + font-weight: 600; + color: var(--lux-ink, var(--text-color)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.25; +} +.scene-target-type { + align-self: flex-start; + font-family: var(--font-mono); + font-size: 0.6rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + color: color-mix(in srgb, var(--st-ch) 70%, var(--lux-ink-dim, var(--text-secondary))); + padding: 1px 5px; + border: 1px solid color-mix(in srgb, var(--st-ch) 28%, transparent); + border-radius: 2px; + line-height: 1.2; +} + +.scene-target-remove { + grid-column: 4; + background: none; + border: 1px solid transparent; + color: var(--lux-ink-dim, var(--text-secondary)); + width: 26px; + height: 26px; + border-radius: 5px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0.7; + transition: + opacity 0.15s ease, + color 0.15s ease, + background 0.15s ease, + border-color 0.15s ease; +} +.scene-target-remove:hover, +.scene-target-remove:focus-visible { + opacity: 1; + color: var(--ch-coral, var(--danger-color, #ff5e5e)); + background: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 10%, transparent); + border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 35%, transparent); + outline: none; +} +.scene-target-remove .icon, +.scene-target-remove svg { width: 14px; height: 14px; } + +/* Add-target slot — full-width dashed cyan channel, reads like an + empty patch-bay slot waiting for an insertion. */ +.scene-target-add-slot { + --st-ch: var(--ch-cyan, var(--info-color, #00d8ff)); + margin-top: 8px; + width: 100%; + padding: 9px 12px; + background: + repeating-linear-gradient(135deg, + color-mix(in srgb, var(--st-ch) 5%, transparent) 0 6px, + transparent 6px 12px); + border: 1px dashed color-mix(in srgb, var(--st-ch) 50%, var(--lux-line, var(--border-color))); + border-radius: var(--lux-r-md, 6px); + color: color-mix(in srgb, var(--st-ch) 80%, var(--lux-ink, var(--text-color))); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.14em; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease, + transform 0.15s ease; +} +.scene-target-add-slot::before { + content: '+'; + font-family: var(--font-mono); + font-size: 1.05rem; + font-weight: 700; + line-height: 1; +} +.scene-target-add-slot:hover:not(:disabled) { + background: + repeating-linear-gradient(135deg, + color-mix(in srgb, var(--st-ch) 12%, transparent) 0 6px, + transparent 6px 12px); + border-color: color-mix(in srgb, var(--st-ch) 75%, transparent); + color: var(--st-ch); +} +.scene-target-add-slot:active:not(:disabled) { + transform: translateY(1px); +} +.scene-target-add-slot:disabled { + opacity: 0.45; + cursor: not-allowed; } /* ── Icon Select (reusable type picker) ──────────────────────── */ diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css index 822b54b..805c799 100644 --- a/server/src/ledgrab/static/css/modal.css +++ b/server/src/ledgrab/static/css/modal.css @@ -3364,58 +3364,128 @@ flex: 1; } -/* ── HA Light Mapping rows ────────────────────────────────── */ +/* ── HA Light Mapping rows ────────────────────────────────────── + Cyan rack-fixture channels, paired with the Routing section + (data-ch="cyan") in ha-light-editor.html. Each row reads as a + numbered fixture: counter · lightbulb plate · entity name · remove. + Slot indices are rendered via CSS counter so removing a middle row + automatically renumbers the rest — no JS bookkeeping needed. */ #ha-light-mappings-list { + --hm-ch: var(--ch-cyan, var(--info-color, #00d8ff)); + counter-reset: hm-slot; display: flex; flex-direction: column; - gap: 8px; + gap: 6px; margin-bottom: 4px; } .ha-light-mapping-row { - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 10px; - background: var(--bg-secondary, var(--bg-color)); + --hm-ch: var(--ch-cyan, var(--info-color, #00d8ff)); + counter-increment: hm-slot; + position: relative; + display: flex; + flex-direction: column; + border: 1px solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-md, 6px); + background: + linear-gradient(180deg, + color-mix(in srgb, var(--hm-ch) 4%, var(--lux-bg-2, var(--bg-secondary))) 0%, + color-mix(in srgb, var(--lux-bg-1, var(--card-bg)) 70%, transparent) 100%); + overflow: hidden; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} +.ha-light-mapping-row:hover { + border-color: color-mix(in srgb, var(--hm-ch) 50%, var(--lux-line, var(--border-color))); + box-shadow: + inset 2px 0 0 color-mix(in srgb, var(--hm-ch) 80%, transparent), + 0 1px 0 color-mix(in srgb, var(--hm-ch) 14%, transparent); } .ha-mapping-header { - display: flex; - justify-content: space-between; + display: grid; + grid-template-columns: 28px 32px 1fr auto; align-items: center; - margin-bottom: 8px; + gap: 10px; + padding: 6px 8px 6px 6px; + border-bottom: 1px solid color-mix(in srgb, var(--hm-ch) 18%, var(--lux-line, var(--border-color))); } - -.ha-mapping-header .ha-mapping-label { +.ha-mapping-header::before { + content: counter(hm-slot, decimal-leading-zero); + grid-column: 1; + justify-self: center; + font-family: var(--font-mono); + font-size: 0.65rem; font-weight: 600; - font-size: 0.85rem; + letter-spacing: 0.06em; + color: color-mix(in srgb, var(--hm-ch) 75%, var(--lux-ink-dim, var(--text-secondary))); + opacity: 0.85; +} + +.ha-mapping-icon { + grid-column: 2; + width: 32px; + height: 32px; display: flex; align-items: center; - gap: 6px; - color: var(--text-secondary); + justify-content: center; + color: var(--hm-ch); + background: color-mix(in srgb, var(--hm-ch) 9%, transparent); + border: 1px solid color-mix(in srgb, var(--hm-ch) 22%, transparent); + border-radius: 5px; + flex-shrink: 0; +} +.ha-mapping-icon svg, +.ha-mapping-icon .icon { width: 18px; height: 18px; } + +.ha-mapping-name { + grid-column: 3; + min-width: 0; + font-weight: 600; + color: var(--lux-ink, var(--text-color)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.25; +} +.ha-mapping-name.is-empty { + font-weight: 500; + color: var(--lux-ink-dim, var(--text-secondary)); + font-style: italic; } -.ha-mapping-header .ha-mapping-label .icon { - width: 16px; - height: 16px; - opacity: 0.7; -} - -.btn-remove-mapping { +.ha-mapping-remove-btn { + grid-column: 4; background: none; - border: none; - color: var(--danger-color, #dc3545); + border: 1px solid transparent; + color: var(--lux-ink-dim, var(--text-secondary)); cursor: pointer; - padding: 2px 6px; + width: 26px; + height: 26px; + border-radius: 5px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; opacity: 0.7; - transition: opacity 0.15s; + transition: + opacity 0.15s ease, + color 0.15s ease, + background 0.15s ease, + border-color 0.15s ease; } - -.btn-remove-mapping .icon { - width: 16px; - height: 16px; +.ha-mapping-remove-btn:hover, +.ha-mapping-remove-btn:focus-visible { + opacity: 1; + color: var(--ch-coral, var(--danger-color, #ff5e5e)); + background: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 10%, transparent); + border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 35%, transparent); + outline: none; } +.ha-mapping-remove-btn .icon, +.ha-mapping-remove-btn svg { width: 14px; height: 14px; } /* HA Light color swatches */ .ha-light-swatches { @@ -3454,13 +3524,19 @@ display: flex; flex-direction: column; gap: 8px; + padding: 8px 10px 10px; } -.ha-mapping-field label { +.ha-mapping-field label, +.ha-mapping-range-row label { display: block; - font-size: 0.85rem; - margin-bottom: 3px; - color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.6rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + margin-bottom: 4px; + color: color-mix(in srgb, var(--hm-ch, var(--ch-cyan, #00d8ff)) 60%, var(--lux-ink-dim, var(--text-secondary))); } .ha-mapping-range-row { @@ -3469,11 +3545,60 @@ gap: 8px; } -.ha-mapping-range-row label { - display: block; - font-size: 0.85rem; - margin-bottom: 3px; - color: var(--text-muted); +/* When color_vs mode renders only the brightness column, don't stretch + it across the full width — keep it natural-sized so the field reads + as a value, not a billboard. */ +.ha-mapping-range-row > div:only-child { + grid-column: 1 / 2; + max-width: 180px; +} + +/* "+ Add Mapping" slot — full-width dashed cyan channel, mirrors the + scene-target add slot for vocabulary consistency. */ +.ha-mapping-add-slot { + --hm-ch: var(--ch-cyan, var(--info-color, #00d8ff)); + margin-top: 6px; + width: 100%; + padding: 9px 12px; + background: + repeating-linear-gradient(135deg, + color-mix(in srgb, var(--hm-ch) 5%, transparent) 0 6px, + transparent 6px 12px); + border: 1px dashed color-mix(in srgb, var(--hm-ch) 50%, var(--lux-line, var(--border-color))); + border-radius: var(--lux-r-md, 6px); + color: color-mix(in srgb, var(--hm-ch) 80%, var(--lux-ink, var(--text-color))); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.14em; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease, + transform 0.15s ease; +} +.ha-mapping-add-slot::before { + content: '+'; + font-family: var(--font-mono); + font-size: 1.05rem; + font-weight: 700; + line-height: 1; +} +.ha-mapping-add-slot:hover { + background: + repeating-linear-gradient(135deg, + color-mix(in srgb, var(--hm-ch) 12%, transparent) 0 6px, + transparent 6px 12px); + border-color: color-mix(in srgb, var(--hm-ch) 75%, transparent); + color: var(--hm-ch); +} +.ha-mapping-add-slot:active { + transform: translateY(1px); } /* ── Custom gradient presets list ───────────────────────────── */ @@ -3829,48 +3954,96 @@ font-size: 0.85rem; } -/* ── Composite layer editor ────────────────────────────────────── */ +/* ── Composite layer editor ──────────────────────────────────────── + Mixer-strip channel rack: each layer reads as a numbered strip with + a violet rail + blend chip. Header collapses/expands the body via + the .composite-layer-expanded class on the item; class names are + preserved so composite.ts wiring (toggle, drag, sync) is untouched. */ #composite-layers-list { + --cl-ch: var(--ch-violet, #8b7eff); + counter-reset: cl-slot; display: flex; flex-direction: column; - gap: 8px; + gap: 6px; margin-bottom: 8px; } .composite-layer-item { + --cl-ch: var(--ch-violet, #8b7eff); + counter-increment: cl-slot; + position: relative; display: flex; flex-direction: column; - gap: 4px; - padding: 10px; - border: 1px solid var(--border-color); - border-radius: 6px; - background: var(--bg-secondary, var(--bg-color)); + gap: 0; + padding: 0; + border: 1px solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-md, 6px); + background: + linear-gradient(180deg, + color-mix(in srgb, var(--cl-ch) 4%, var(--lux-bg-2, var(--bg-secondary))) 0%, + color-mix(in srgb, var(--lux-bg-1, var(--card-bg)) 70%, transparent) 100%); + overflow: hidden; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} +.composite-layer-item:hover { + border-color: color-mix(in srgb, var(--cl-ch) 50%, var(--lux-line, var(--border-color))); + box-shadow: + inset 2px 0 0 color-mix(in srgb, var(--cl-ch) 80%, transparent), + 0 1px 0 color-mix(in srgb, var(--cl-ch) 12%, transparent); +} +.composite-layer-expanded { + border-color: color-mix(in srgb, var(--cl-ch) 55%, var(--lux-line, var(--border-color))); + box-shadow: + inset 2px 0 0 var(--cl-ch), + 0 0 0 1px color-mix(in srgb, var(--cl-ch) 18%, transparent), + 0 4px 14px color-mix(in srgb, var(--cl-ch) 12%, transparent); } .composite-layer-header { - display: flex; + display: grid; + grid-template-columns: 28px 18px 18px 1fr auto auto; + grid-auto-flow: dense; /* DOM order is drag→chevron, but visual order is chevron→drag — let the grid backfill the earlier column. Without `dense`, the chevron skips to row 2 and drags every other item with it. */ align-items: center; - gap: 6px; + gap: 8px; + padding: 7px 8px 7px 6px; cursor: pointer; user-select: none; } - -.composite-layer-expand-btn { - font-size: 0.6rem; - color: var(--text-secondary); - transition: transform 0.15s ease; - flex-shrink: 0; - width: 12px; - text-align: center; +.composite-layer-header::before { + content: counter(cl-slot, decimal-leading-zero); + grid-column: 1; + justify-self: center; + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.06em; + color: color-mix(in srgb, var(--cl-ch) 75%, var(--lux-ink-dim, var(--text-secondary))); + opacity: 0.85; } +.composite-layer-expand-btn { + grid-column: 2; + justify-self: center; + font-size: 0.55rem; + color: color-mix(in srgb, var(--cl-ch) 60%, var(--lux-ink-dim, var(--text-secondary))); + transition: transform 0.18s ease, color 0.15s ease; + width: 14px; + text-align: center; + line-height: 1; +} +.composite-layer-item:hover .composite-layer-expand-btn { + color: var(--cl-ch); +} .composite-layer-expanded .composite-layer-expand-btn { transform: rotate(90deg); + color: var(--cl-ch); } .composite-layer-summary { - flex: 1; + grid-column: 4; min-width: 0; display: flex; align-items: center; @@ -3879,18 +4052,27 @@ } .composite-layer-summary-name { - font-size: 0.85rem; + font-size: 0.88rem; + font-weight: 600; + color: var(--lux-ink, var(--text-color)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + line-height: 1.25; } .composite-layer-summary-blend { - font-size: 0.75rem; - color: var(--text-secondary); - background: var(--bg-secondary); + font-family: var(--font-mono); + font-size: 0.6rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + color: color-mix(in srgb, var(--cl-ch) 75%, var(--lux-ink-dim, var(--text-secondary))); + background: color-mix(in srgb, var(--cl-ch) 8%, transparent); padding: 1px 6px; - border-radius: 3px; + border: 1px solid color-mix(in srgb, var(--cl-ch) 28%, transparent); + border-radius: 2px; + line-height: 1.3; white-space: nowrap; flex-shrink: 0; } @@ -3898,7 +4080,7 @@ .composite-layer-body-wrapper { display: grid; grid-template-rows: 0fr; - transition: grid-template-rows 0.2s ease; + transition: grid-template-rows 0.22s ease; } .composite-layer-expanded .composite-layer-body-wrapper { @@ -3908,22 +4090,24 @@ .composite-layer-body { display: flex; flex-direction: column; - gap: 4px; - padding-top: 0; + gap: 6px; + padding: 0 10px 0 36px; /* indent under index/chevron rail */ overflow: hidden; min-height: 0; - transition: padding-top 0.2s ease; + transition: padding 0.22s ease; font-size: 0.85rem; + border-top: 1px solid transparent; } .composite-layer-expanded .composite-layer-body { - padding-top: 4px; + padding: 8px 10px 10px 36px; + border-top-color: color-mix(in srgb, var(--cl-ch) 22%, var(--lux-line, var(--border-color))); } .composite-layer-row { display: flex; align-items: center; - gap: 6px; + gap: 8px; } .composite-layer-source { @@ -3931,10 +4115,17 @@ min-width: 0; } -.composite-layer-brightness-label { +.composite-layer-brightness-label, +.composite-layer-opacity-label { flex-shrink: 0; - width: 90px; - color: var(--text-secondary); + width: 96px; + font-family: var(--font-mono); + font-size: 0.62rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: color-mix(in srgb, var(--cl-ch) 60%, var(--lux-ink-dim, var(--text-secondary))); + white-space: nowrap; } .composite-layer-brightness, @@ -3944,12 +4135,7 @@ } .composite-layer-blend { - width: 100px; - flex-shrink: 0; -} - -.composite-layer-opacity-label { - white-space: nowrap; + width: 110px; flex-shrink: 0; } @@ -3960,34 +4146,68 @@ } .composite-layer-toggle { + grid-column: 5; flex-shrink: 0; } .composite-layer-remove-btn { + grid-column: 6; background: none; - border: none; - color: var(--danger-color, #dc3545); + border: 1px solid transparent; + color: var(--lux-ink-dim, var(--text-secondary)); cursor: pointer; - padding: 2px 6px; + width: 26px; + height: 26px; + border-radius: 5px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; opacity: 0.7; - transition: opacity 0.15s; + transition: + opacity 0.15s ease, + color 0.15s ease, + background 0.15s ease, + border-color 0.15s ease; } .composite-layer-remove-btn .icon { - width: 16px; - height: 16px; + width: 14px; + height: 14px; } -.composite-layer-remove-btn:hover { +.composite-layer-remove-btn:hover, +.composite-layer-remove-btn:focus-visible { opacity: 1; + color: var(--ch-coral, var(--danger-color, #ff5e5e)); + background: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 10%, transparent); + border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 35%, transparent); + outline: none; +} + +/* Override the flex from .composite-layer-row — use grid so each + cluster (toggle / fields / reverse) lives in its own column and + can't bleed into its neighbour. minmax(0, 1fr) lets the middle + column shrink under tight widths instead of pushing reverse over + the END input. */ +.composite-layer-range-row { + display: grid; + grid-template-columns: 96px minmax(0, 1fr) auto; + column-gap: 10px; + row-gap: 6px; + align-items: center; } .composite-layer-range-toggle-label { display: flex; align-items: center; gap: 6px; - flex-shrink: 0; - color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 0.62rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: color-mix(in srgb, var(--cl-ch) 60%, var(--lux-ink-dim, var(--text-secondary))); cursor: pointer; white-space: nowrap; } @@ -3996,22 +4216,26 @@ display: flex; align-items: center; gap: 6px; - flex: 1; min-width: 0; } .composite-layer-range-fields label { - color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 0.6rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--lux-ink-dim, var(--text-secondary)); flex-shrink: 0; } .composite-layer-range-fields input[type="number"] { - width: 60px; + width: 64px; flex-shrink: 0; } .composite-layer-range-disabled { - opacity: 0.35; + opacity: 0.4; pointer-events: none; } @@ -4019,34 +4243,89 @@ display: flex; align-items: center; gap: 4px; - margin-left: auto; white-space: nowrap; - color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 0.62rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: color-mix(in srgb, var(--cl-ch) 60%, var(--lux-ink-dim, var(--text-secondary))); + cursor: pointer; } /* ── Composite layer drag-to-reorder ── */ .composite-layer-drag-handle { + grid-column: 3; cursor: grab; opacity: 0; - color: var(--text-secondary); - font-size: 0.75rem; + color: color-mix(in srgb, var(--cl-ch) 70%, var(--lux-ink-dim, var(--text-secondary))); + font-size: 0.85rem; line-height: 1; padding: 2px 4px; border-radius: 3px; - transition: opacity 0.2s ease; + transition: opacity 0.18s ease, color 0.15s ease, background 0.15s ease; user-select: none; touch-action: none; flex-shrink: 0; } .composite-layer-item:hover .composite-layer-drag-handle { - opacity: 0.5; + opacity: 0.55; } .composite-layer-drag-handle:hover { opacity: 1 !important; - background: var(--border-color); + color: var(--cl-ch); + background: color-mix(in srgb, var(--cl-ch) 14%, transparent); +} + +/* "+ Add Layer" slot — full-width dashed violet channel, mirrors the + scene-target add slot in components.css for vocabulary consistency. */ +.composite-add-layer-slot { + --cl-ch: var(--ch-violet, #8b7eff); + margin-top: 2px; + width: 100%; + padding: 9px 12px; + background: + repeating-linear-gradient(135deg, + color-mix(in srgb, var(--cl-ch) 5%, transparent) 0 6px, + transparent 6px 12px); + border: 1px dashed color-mix(in srgb, var(--cl-ch) 50%, var(--lux-line, var(--border-color))); + border-radius: var(--lux-r-md, 6px); + color: color-mix(in srgb, var(--cl-ch) 80%, var(--lux-ink, var(--text-color))); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.14em; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease, + transform 0.15s ease; +} +.composite-add-layer-slot::before { + content: '+'; + font-family: var(--font-mono); + font-size: 1.05rem; + font-weight: 700; + line-height: 1; +} +.composite-add-layer-slot:hover { + background: + repeating-linear-gradient(135deg, + color-mix(in srgb, var(--cl-ch) 12%, transparent) 0 6px, + transparent 6px 12px); + border-color: color-mix(in srgb, var(--cl-ch) 75%, transparent); + color: var(--cl-ch); +} +.composite-add-layer-slot:active { + transform: translateY(1px); } /* ── Weather source location row ── */ @@ -4082,16 +4361,22 @@ position: fixed; z-index: var(--z-modal-top); pointer-events: none; - opacity: 0.92; - transform: scale(1.02); - box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25); + opacity: 0.95; + transform: scale(1.015); + border-color: color-mix(in srgb, var(--ch-violet, #8b7eff) 60%, var(--lux-line, var(--border-color))) !important; + box-shadow: + inset 2px 0 0 var(--ch-violet, #8b7eff), + 0 14px 40px color-mix(in srgb, var(--ch-violet, #8b7eff) 22%, rgba(0, 0, 0, 0.45)); will-change: top; } .composite-layer-drag-placeholder { - border: 2px dashed var(--primary-color); - border-radius: 4px; - background: rgba(33, 150, 243, 0.04); + border: 1px dashed color-mix(in srgb, var(--ch-violet, #8b7eff) 60%, var(--lux-line, var(--border-color))); + border-radius: var(--lux-r-md, 6px); + background: + repeating-linear-gradient(135deg, + color-mix(in srgb, var(--ch-violet, #8b7eff) 8%, transparent) 0 6px, + transparent 6px 12px); min-height: 42px; transition: height 0.15s ease; } diff --git a/server/src/ledgrab/static/js/core/light-target-editor.ts b/server/src/ledgrab/static/js/core/light-target-editor.ts new file mode 100644 index 0000000..ab7113a --- /dev/null +++ b/server/src/ledgrab/static/js/core/light-target-editor.ts @@ -0,0 +1,228 @@ +/** + * Shared helpers for "light target" editors (HA Light + Z2M Light). + * + * Both editors expose the same five tunable knobs (brightness, update_rate, + * transition, color_tolerance, min_brightness_threshold), the same unified + * Color Source picker (CSS sources + colour-returning value sources), and + * the same IconSelect-driven stop_action picker. This module hosts the + * boilerplate for those three concerns so the per-protocol editor files + * stay focused on what genuinely differs (URLs, mapping row chrome, + * binding-target picker, card chips). + */ + +import { BindableScalarWidget } from './bindable-scalar.ts'; +import { IconSelect, type IconSelectItem } from './icon-select.ts'; +import { getColorStripIcon, getValueSourceIcon } from './icons.ts'; + +// ────────────────────────────────────────────────────────────────────── +// Bindable scalar widget bundle +// ────────────────────────────────────────────────────────────────────── + +interface WidgetConfig { + min: number; + max: number; + step: number; + default: number; + format: (v: number) => string; +} + +const DEFAULT_WIDGET_CONFIG: Record = { + brightness: { min: 0, max: 1, step: 0.05, default: 1.0, format: (v) => v.toFixed(2) }, + updateRate: { min: 0.5, max: 5.0, step: 0.1, default: 2.0, format: (v) => v.toFixed(1) }, + transition: { min: 0.0, max: 10, step: 0.1, default: 0.5, format: (v) => v.toFixed(1) }, + colorTolerance: { min: 0, max: 50, step: 1, default: 5, format: (v) => String(Math.round(v)) }, + minBrightnessThreshold: { min: 0, max: 254, step: 1, default: 0, format: (v) => String(Math.round(v)) }, +}; + +export type WidgetKey = + | 'brightness' + | 'updateRate' + | 'transition' + | 'colorTolerance' + | 'minBrightnessThreshold'; + +/** Maps a WidgetKey to its `id`/`container` slug. */ +const WIDGET_SLUG: Record = { + brightness: 'brightness', + updateRate: 'update-rate', + transition: 'transition', + colorTolerance: 'color-tolerance', + minBrightnessThreshold: 'min-brightness-threshold', +}; + +export interface LightTargetWidgetsConfig { + /** Prefix for both the BindableScalarWidget `idPrefix` and the container + * element id (e.g. `'ha-light-editor'` → container `'ha-light-editor-brightness-container'`). */ + idPrefix: string; + /** Value-source list provider for the binding mode of every widget. */ + valueSources: () => Array<{ id: string; name: string; source_type: string }>; + /** Per-widget overrides (e.g. wider update_rate range for Z2M). Each + * field is `Partial` and merges over the defaults. */ + overrides?: Partial>>; +} + +/** + * Lazy bundle of the five BindableScalarWidgets every light-target editor + * uses. Widgets are created on first `ensure(key)` and cached; `destroyAll()` + * tears them down and resets the cache so the editor can be re-opened cleanly. + */ +export class LightTargetWidgets { + private _widgets: Partial> = {}; + + constructor(private opts: LightTargetWidgetsConfig) {} + + /** Lazy-create (or return cached) widget for the given key. */ + ensure(key: WidgetKey): BindableScalarWidget { + let w = this._widgets[key]; + if (w) return w; + + const cfg = { ...DEFAULT_WIDGET_CONFIG[key], ...(this.opts.overrides?.[key] || {}) }; + const slug = WIDGET_SLUG[key]; + const container = document.getElementById(`${this.opts.idPrefix}-${slug}-container`); + if (!container) { + throw new Error(`LightTargetWidgets: container not found for key '${key}' (expected #${this.opts.idPrefix}-${slug}-container)`); + } + w = new BindableScalarWidget({ + container, + idPrefix: `${this.opts.idPrefix}-${slug}`, + min: cfg.min, + max: cfg.max, + step: cfg.step, + default: cfg.default, + format: cfg.format, + valueSources: this.opts.valueSources, + }); + this._widgets[key] = w; + return w; + } + + /** Apply target-response values to all five widgets, lazy-creating each. + * Missing fields fall back to the per-key default. */ + applyValues(data: { + brightness?: any; + update_rate?: any; + transition?: any; + color_tolerance?: any; + min_brightness_threshold?: any; + }): void { + this.ensure('brightness').setValue(data.brightness ?? this._defaultFor('brightness')); + this.ensure('updateRate').setValue(data.update_rate ?? this._defaultFor('updateRate')); + this.ensure('transition').setValue(data.transition ?? this._defaultFor('transition')); + this.ensure('colorTolerance').setValue(data.color_tolerance ?? this._defaultFor('colorTolerance')); + this.ensure('minBrightnessThreshold').setValue(data.min_brightness_threshold ?? this._defaultFor('minBrightnessThreshold')); + } + + /** Reset all widgets to their (override-aware) default values. */ + applyDefaults(): void { + this.ensure('brightness').setValue(this._defaultFor('brightness')); + this.ensure('updateRate').setValue(this._defaultFor('updateRate')); + this.ensure('transition').setValue(this._defaultFor('transition')); + this.ensure('colorTolerance').setValue(this._defaultFor('colorTolerance')); + this.ensure('minBrightnessThreshold').setValue(this._defaultFor('minBrightnessThreshold')); + } + + /** Read the current value (BindableFloat-shaped) for a widget. Auto-creates if not yet ensured. */ + getValue(key: WidgetKey) { + return this.ensure(key).getValue(); + } + + destroyAll(): void { + for (const key of Object.keys(this._widgets) as WidgetKey[]) { + this._widgets[key]?.destroy(); + } + this._widgets = {}; + } + + private _defaultFor(key: WidgetKey): number { + return this.opts.overrides?.[key]?.default ?? DEFAULT_WIDGET_CONFIG[key].default; + } +} + +// ────────────────────────────────────────────────────────────────────── +// Unified Color Source picker (CSS + colour value sources) +// ────────────────────────────────────────────────────────────────────── + +export interface ColorSourcePickerLabels { + /** Header for the CSS sources section (e.g. "Color strip"). */ + cssHeader: string; + /** Header for the colour value sources section (e.g. "Color value source"). */ + colorVsHeader: string; +} + +/** + * Build the dual-section item list for the unified Color Source picker. + * Items use the `css:` / `cvs:` value encoding that both editors + * decode in their `onChange` handlers. + */ +export function buildColorSourcePickerItems( + cssSources: Array<{ id: string; name: string; source_type: string }>, + colorValueSources: Array<{ id: string; name: string; source_type: string }>, + labels: ColorSourcePickerLabels, +): Array<{ value: string; label: string; icon: string; desc?: string; header?: boolean }> { + const items: Array<{ value: string; label: string; icon: string; desc?: string; header?: boolean }> = []; + + if (cssSources.length > 0) { + items.push({ value: '', label: labels.cssHeader, icon: '', header: true }); + for (const s of cssSources) { + items.push({ + value: `css:${s.id}`, + label: s.name, + icon: getColorStripIcon(s.source_type), + desc: s.source_type, + }); + } + } + + if (colorValueSources.length > 0) { + items.push({ value: '', label: labels.colorVsHeader, icon: '', header: true }); + for (const s of colorValueSources) { + items.push({ + value: `cvs:${s.id}`, + label: s.name, + icon: getValueSourceIcon(s.source_type), + desc: s.source_type, + }); + } + } + + return items; +} + +/** Decode a unified picker value (`css:abc` / `cvs:abc` / `''`) into kind+id. */ +export function decodeColorSourceValue(raw: string): { kind: 'css' | 'color_vs'; id: string } { + const kind: 'css' | 'color_vs' = raw.startsWith('cvs:') ? 'color_vs' : 'css'; + const id = raw.includes(':') ? raw.slice(raw.indexOf(':') + 1) : ''; + return { kind, id }; +} + +/** Encode a kind+id pair into the unified picker's option-value string. */ +export function encodeColorSourceValue(kind: 'css' | 'color_vs', id: string): string { + if (!id) return ''; + return `${kind === 'color_vs' ? 'cvs' : 'css'}:${id}`; +} + +// ────────────────────────────────────────────────────────────────────── +// Stop-action IconSelect +// ────────────────────────────────────────────────────────────────────── + +/** + * Wire (or refresh) an IconSelect for a stop-action ` `; + const idleSelect = container.querySelector('.rule-when-idle') as HTMLSelectElement; + new IconSelect({ + target: idleSelect, + items: [ + { value: 'true', icon: _icon(P.moon), label: t('automations.rule.system_idle.when_idle'), desc: t('automations.rule.system_idle.when_idle.desc') }, + { value: 'false', icon: _icon(P.activity), label: t('automations.rule.system_idle.when_active'), desc: t('automations.rule.system_idle.when_active.desc') }, + ], + columns: 2, + } as any); return; } if (type === 'display_state') { diff --git a/server/src/ledgrab/static/js/features/card-modes.ts b/server/src/ledgrab/static/js/features/card-modes.ts index 889cb97..c503b50 100644 --- a/server/src/ledgrab/static/js/features/card-modes.ts +++ b/server/src/ledgrab/static/js/features/card-modes.ts @@ -30,6 +30,14 @@ export const CARD_MODES: readonly CardMode[] = ['comfortable', 'compact', 'dense const DEFAULT_MODE: CardMode = 'compact'; +/** Per-surface defaults — overrides `DEFAULT_MODE` for specific surfaces + * whose preferred initial mode differs from (or must be pinned regardless + * of) the global default. Resolved by `getCardMode` only when the user + * has not set an explicit value for the surface. */ +const SURFACE_DEFAULTS: Readonly> = { + 'dashboard-running': 'compact', +}; + export interface CardModePrefsV1 { version: 1; surfaces: Record; @@ -82,10 +90,11 @@ export function getCardModePrefs(): CardModePrefsV1 { return _clone(_current); } -/** Effective mode for a surface — returns the configured value or the - * default when the surface is unset. */ +/** Effective mode for a surface — returns the user's configured value if + * set, else the per-surface default (`SURFACE_DEFAULTS`), else the + * global `DEFAULT_MODE`. */ export function getCardMode(surface: string): CardMode { - return _current.surfaces[surface] ?? DEFAULT_MODE; + return _current.surfaces[surface] ?? SURFACE_DEFAULTS[surface] ?? DEFAULT_MODE; } /** Persist a surface's mode. Updates cache, fires subscribers, diff --git a/server/src/ledgrab/static/js/features/color-strips/cards.ts b/server/src/ledgrab/static/js/features/color-strips/cards.ts index 766b26f..21d3fdc 100644 --- a/server/src/ledgrab/static/js/features/color-strips/cards.ts +++ b/server/src/ledgrab/static/js/features/color-strips/cards.ts @@ -5,12 +5,12 @@ import { escapeHtml } from '../../core/api.ts'; import { - _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, - colorStripSourcesCache, gradientsCache, GradientEntity, + _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, _cachedValueSources, + _cachedAssets, colorStripSourcesCache, gradientsCache, GradientEntity, } from '../../core/state.ts'; import { t } from '../../core/i18n.ts'; import { - getColorStripIcon, getPictureSourceIcon, + getColorStripIcon, getPictureSourceIcon, getValueSourceIcon, ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_OVERLAY, ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC, ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, @@ -19,8 +19,8 @@ import { } from '../../core/icons.ts'; import { wrapCard } from '../../core/card-colors.ts'; import type { ModCardOpts } from '../../core/mod-card.ts'; -import type { ColorStripSource } from '../../types.ts'; -import { bindableValue, bindableColor } from '../../types.ts'; +import type { BindableColor, ColorStripSource } from '../../types.ts'; +import { bindableValue, bindableColor, bindableColorSourceId } from '../../types.ts'; import { renderTagChips } from '../../core/tag-input.ts'; import { rgbArrayToHex } from '../css-gradient-editor.ts'; import { makeCardIconFields } from '../../core/card-icon.ts'; @@ -56,6 +56,30 @@ function _getGradients(): GradientEntity[] { return gradientsCache.data || []; } +/** + * Render a bindable color as either a static color swatch or a crosslink + * badge to the bound value source. Falls back to the static hex badge + * when the color is not bound (no `source_id`). + */ +function _bindableColorBadge( + color: BindableColor | undefined, + fallback: number[], + title: string, +): string { + const sourceId = bindableColorSourceId(color); + if (sourceId) { + const vs = (_cachedValueSources || []).find(v => v.id === sourceId); + if (vs) { + return `${getValueSourceIcon(vs.source_type)} ${escapeHtml(vs.name)}`; + } + return `${ICON_LINK_SOURCE} ${escapeHtml(sourceId)}`; + } + const hex = rgbArrayToHex(bindableColor(color, fallback)); + return ` + ${hex.toUpperCase()} + `; +} + function _gradientEntityStripHTML(stops: Array<{ position: number; color: number[] }>, w = 80, h = 16) { const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', '); return ``; @@ -73,11 +97,9 @@ const NON_PICTURE_TYPES = new Set([ const CSS_CARD_RENDERERS: Record = { static: (source, { clockBadge, animBadge }) => { - const hexColor = rgbArrayToHex(bindableColor(source.color, [255,255,255])); + const colorBadge = _bindableColorBadge(source.color, [255, 255, 255], t('color_strip.static_color')); return ` - - ${hexColor.toUpperCase()} - + ${colorBadge} ${animBadge} ${clockBadge} `; @@ -145,13 +167,11 @@ const CSS_CARD_RENDERERS: Record = { `; }, api_input: (source) => { - const fbColor = rgbArrayToHex(bindableColor(source.fallback_color, [0, 0, 0])); + const fbBadge = _bindableColorBadge(source.fallback_color, [0, 0, 0], t('color_strip.api_input.fallback_color')); const timeoutVal = bindableValue(source.timeout, 5.0).toFixed(1); const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear'; return ` - - ${fbColor.toUpperCase()} - + ${fbBadge} ${ICON_TIMER} ${timeoutVal}s ${escapeHtml(interpLabel)} `; @@ -162,12 +182,24 @@ const CSS_CARD_RENDERERS: Record = { const defColorRgb = bindableColor(source.default_color as any, [255, 255, 255]); const defColorHex = rgbArrayToHex(defColorRgb); const appCount = source.app_colors ? Object.keys(source.app_colors).length : 0; + const soundBadge = (() => { + if (!source.sound_asset_id) return ''; + const assetId = source.sound_asset_id; + const asset = (_cachedAssets || []).find(a => a.id === assetId); + const name = asset?.name || assetId; + const linkCls = asset ? ' stream-card-link' : ''; + const onclick = asset + ? ` onclick="event.stopPropagation(); navigateToCard('streams','assets','assets','data-id','${assetId}')"` + : ''; + return `${ICON_MUSIC} ${escapeHtml(name)}`; + })(); return ` ${ICON_BELL} ${escapeHtml(effectLabel)} ${ICON_TIMER} ${durationVal}ms ${defColorHex.toUpperCase()} + ${soundBadge} ${appCount > 0 ? `${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}` : ''} `; }, @@ -180,12 +212,10 @@ const CSS_CARD_RENDERERS: Record = { `; }, candlelight: (source, { clockBadge }) => { - const hexColor = rgbArrayToHex(bindableColor(source.color, [255, 147, 41])); + const colorBadge = _bindableColorBadge(source.color, [255, 147, 41], t('color_strip.candlelight.color')); const numCandles = source.num_candles ?? 3; return ` - - ${hexColor.toUpperCase()} - + ${colorBadge} ${numCandles} ${t('color_strip.candlelight.num_candles')} ${clockBadge} `; @@ -361,7 +391,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap: }, foot: { patchState: 'idle', - patchLabel: 'STRIP', + patchLabel: t('patch.strip'), iconActions, }, }; diff --git a/server/src/ledgrab/static/js/features/dashboard.ts b/server/src/ledgrab/static/js/features/dashboard.ts index 973ca5c..55b4687 100644 --- a/server/src/ledgrab/static/js/features/dashboard.ts +++ b/server/src/ledgrab/static/js/features/dashboard.ts @@ -359,7 +359,7 @@ function _renderIntegrationCard(conn: HomeAssistantConnectionStatus): string { : (host ? `${host} · ${t('ha_source.disconnected')}` : t('ha_source.disconnected')); const short = (conn.source_id || '').replace(/^src_/, '').slice(0, 2).toUpperCase() || 'HA'; const ledCls = conn.connected ? 'led on blink' : 'led'; - const patchLabel = conn.connected ? 'ONLINE' : 'OFFLINE'; + const patchLabel = conn.connected ? t('patch.online') : t('patch.offline'); const patchLive = conn.connected ? ' is-live' : ''; return `
-
PATCHED
+
${escapeHtml(t('patch.patched'))}
`; @@ -1158,7 +1158,7 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
-
STANDBY
+
${escapeHtml(t('patch.standby'))}
`; diff --git a/server/src/ledgrab/static/js/features/devices.ts b/server/src/ledgrab/static/js/features/devices.ts index 385a55a..966c2b5 100644 --- a/server/src/ledgrab/static/js/features/devices.ts +++ b/server/src/ledgrab/static/js/features/devices.ts @@ -249,9 +249,9 @@ export function createDeviceCard(device: Device & { state?: any }) { devLastChecked == null ? 'idle' : isOnline ? 'live' : 'offline'; - const patchLabel = devLastChecked == null ? (t('device.health.checking') || 'CHECKING').toUpperCase() - : isOnline ? (t('device.health.online') || 'ONLINE').toUpperCase() - : (t('device.health.offline') || 'OFFLINE').toUpperCase(); + const patchLabel = devLastChecked == null ? t('patch.checking') + : isOnline ? t('patch.online') + : t('patch.offline'); // ── Brightness fader ── const hasBrightness = (device.capabilities || []).includes('brightness_control'); diff --git a/server/src/ledgrab/static/js/features/ha-light-targets.ts b/server/src/ledgrab/static/js/features/ha-light-targets.ts index 956ede9..c876005 100644 --- a/server/src/ledgrab/static/js/features/ha-light-targets.ts +++ b/server/src/ledgrab/static/js/features/ha-light-targets.ts @@ -2,23 +2,33 @@ * HA Light Targets — editor, cards, CRUD for Home Assistant light output targets. */ -import { _cachedHASources, _cachedValueSources, haSourcesCache, colorStripSourcesCache, outputTargetsCache, valueSourcesCache } from '../core/state.ts'; +import { + _cachedHASources, _cachedValueSources, haSourcesCache, + colorStripSourcesCache, outputTargetsCache, valueSourcesCache, + getHAEntityFriendlyName, setHAEntityNames, +} from '../core/state.ts'; import { fetchWithAuth, escapeHtml } from '../core/api.ts'; import { logError } from '../core/log.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm, formatUptime } from '../core/ui.ts'; -import { ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_OK, ICON_WARNING, ICON_CLOCK, ICON_SUN, getColorStripIcon, getValueSourceIcon, getHAEntityIcon } from '../core/icons.ts'; +import { ICON_EDIT, ICON_START, ICON_STOP, ICON_STOP_PLAIN, ICON_TRASH, ICON_OK, ICON_WARNING, ICON_CLOCK, ICON_SUN, getColorStripIcon, getValueSourceIcon, getHAEntityIcon } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { EntitySelect } from '../core/entity-palette.ts'; import { IconSelect } from '../core/icon-select.ts'; +import { + LightTargetWidgets, + buildColorSourcePickerItems, + decodeColorSourceValue, + encodeColorSourceValue, + ensureStopActionIconSelect, +} from '../core/light-target-editor.ts'; import { renderDeviceIconSvg } from '../core/device-icons.ts'; import { wrapCard } from '../core/card-colors.ts'; import type { ModCardOpts, ModChipOpts, ModMetricOpts, ModBtnOpts, ModMenuItemOpts, LedState } from '../core/mod-card.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { openAuthedWs } from '../core/ws-auth.ts'; import { bindableSourceId, bindableValue } from '../types.ts'; -import { BindableScalarWidget } from '../core/bindable-scalar.ts'; const ICON_HA = `${P.home}`; const _icon = (d: string) => `${d}`; @@ -30,16 +40,17 @@ type HALightSourceKind = 'css' | 'color_vs'; let _haLightTagsInput: TagInput | null = null; let _haSourceEntitySelect: EntitySelect | null = null; let _cssSourceEntitySelect: EntitySelect | null = null; -let _brightnessWidget: BindableScalarWidget | null = null; let _mappingEntitySelects: EntitySelect[] = []; let _editorCssSources: any[] = []; let _editorColorValueSources: any[] = []; // value sources with return_type='color' let _cachedHAEntities: any[] = []; // fetched from selected HA source -let _updateRateWidget: BindableScalarWidget | null = null; -let _transitionWidget: BindableScalarWidget | null = null; -let _colorToleranceWidget: BindableScalarWidget | null = null; -let _minBrightnessThresholdWidget: BindableScalarWidget | null = null; let _stopActionIconSelect: IconSelect | null = null; +// Shared bundle of brightness/update_rate/transition/color_tolerance/min_brightness_threshold widgets. +// Defaults match the previous per-widget config — HA-light uses every default unchanged. +const _widgets = new LightTargetWidgets({ + idPrefix: 'ha-light-editor', + valueSources: () => _cachedValueSources, +}); // Active mode + selected colour value source id (only used in color_vs mode). let _editorSourceKind: HALightSourceKind = 'css'; let _editorColorVsId: string = ''; @@ -51,12 +62,8 @@ class HALightEditorModal extends Modal { if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; } if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; } if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; } - if (_brightnessWidget) { _brightnessWidget.destroy(); _brightnessWidget = null; } - if (_updateRateWidget) { _updateRateWidget.destroy(); _updateRateWidget = null; } - if (_transitionWidget) { _transitionWidget.destroy(); _transitionWidget = null; } - if (_colorToleranceWidget) { _colorToleranceWidget.destroy(); _colorToleranceWidget = null; } - if (_minBrightnessThresholdWidget) { _minBrightnessThresholdWidget.destroy(); _minBrightnessThresholdWidget = null; } if (_stopActionIconSelect) { _stopActionIconSelect.destroy(); _stopActionIconSelect = null; } + _widgets.destroyAll(); _destroyMappingEntitySelects(); } @@ -66,11 +73,11 @@ class HALightEditorModal extends Modal { ha_source: (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value, color_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value, source_kind: _editorSourceKind, - brightness: _brightnessWidget ? JSON.stringify(_brightnessWidget.getValue()) : '1.0', - update_rate: _updateRateWidget ? JSON.stringify(_updateRateWidget.getValue()) : '2.0', - transition: _transitionWidget ? JSON.stringify(_transitionWidget.getValue()) : '0.5', - color_tolerance: _colorToleranceWidget ? JSON.stringify(_colorToleranceWidget.getValue()) : '5', - min_brightness_threshold: _minBrightnessThresholdWidget ? JSON.stringify(_minBrightnessThresholdWidget.getValue()) : '0', + brightness: JSON.stringify(_widgets.getValue('brightness')), + update_rate: JSON.stringify(_widgets.getValue('updateRate')), + transition: JSON.stringify(_widgets.getValue('transition')), + color_tolerance: JSON.stringify(_widgets.getValue('colorTolerance')), + min_brightness_threshold: JSON.stringify(_widgets.getValue('minBrightnessThreshold')), stop_action: (document.getElementById('ha-light-editor-stop-action') as HTMLSelectElement).value, mappings: _getMappingsJSON(), tags: JSON.stringify(_haLightTagsInput ? _haLightTagsInput.getValue() : []), @@ -107,43 +114,16 @@ function _isColorValueSource(vs: any): boolean { return !!vs && vs.return_type === 'color'; } -/** - * Build the unified Color Source picker items, split into two sections: - * - * ── Color strip sources ── - * Audio bars (audio) - * Living-room screen (picture) - * ── Color value sources ── - * Sunrise cycle (animated_color) - */ +/** Build the unified Color Source picker items via the shared helper. */ function _buildColorSourcePickerItems(): any[] { - const items: any[] = []; - - if (_editorCssSources.length > 0) { - items.push({ value: '', label: t('ha_light.color_source.css'), icon: '', header: true }); - for (const s of _editorCssSources) { - items.push({ - value: `css:${s.id}`, - label: s.name, - icon: getColorStripIcon(s.source_type), - desc: s.source_type, - }); - } - } - - if (_editorColorValueSources.length > 0) { - items.push({ value: '', label: t('ha_light.color_source.color_vs'), icon: '', header: true }); - for (const s of _editorColorValueSources) { - items.push({ - value: `cvs:${s.id}`, - label: s.name, - icon: getValueSourceIcon(s.source_type), - desc: s.source_type, - }); - } - } - - return items; + return buildColorSourcePickerItems( + _editorCssSources, + _editorColorValueSources, + { + cssHeader: t('ha_light.color_source.css'), + colorVsHeader: t('ha_light.color_source.color_vs'), + }, + ); } function _setMappingsModeHint(): void { @@ -173,6 +153,9 @@ async function _fetchHAEntities(haSourceId: string): Promise { if (!resp.ok) { _cachedHAEntities = []; return; } const data = await resp.json(); _cachedHAEntities = data.entities || []; + // Mirror into the shared cache so card chips/swatches across the + // app pick up friendly names on the next render. + setHAEntityNames(haSourceId, _cachedHAEntities); } catch { _cachedHAEntities = []; } @@ -215,7 +198,6 @@ export function addHALightMapping(data: any = null): void { const list = document.getElementById('ha-light-mappings-list'); if (!list) return; - const idx = list.querySelectorAll('.ha-light-mapping-row').length + 1; const row = document.createElement('div'); row.className = 'ha-light-mapping-row'; @@ -232,6 +214,16 @@ export function addHALightMapping(data: any = null): void { ? `` : ''; + // Header preview: show the resolved friendly name when available, fall + // back to raw entity_id (HA disconnected case), or a placeholder dash + // when no entity is selected yet. + const previewText = (() => { + if (!selectedId) return t('ha_light.mapping.unassigned') || '—'; + const ent = lightEntities.find((e: any) => e.entity_id === selectedId); + return ent ? (ent.friendly_name || ent.entity_id) : selectedId; + })(); + const previewIsEmpty = !selectedId; + // Per-source-kind row layout: CSS shows LED ranges; color_vs hides them and // promotes the brightness scale to a single inline field. const rangeBlock = _editorSourceKind === 'color_vs' @@ -256,10 +248,12 @@ export function addHALightMapping(data: any = null): void { `; + const removeLabel = t('common.delete') || 'Remove'; row.innerHTML = `
- ${_icon(P.lightbulb)} #${idx} - + + ${escapeHtml(previewText)} +
@@ -279,6 +273,20 @@ export function addHALightMapping(data: any = null): void { placeholder: t('ha_light.mapping.search_entity'), }); _mappingEntitySelects.push(es); + + // Live-update the header preview when the user picks a different entity. + // EntitySelect mutates the underlying +
+
` + : `
+
+ + +
+
+ + +
+
+ + +
+
`; + + const removeLabel = t('common.delete') || 'Remove'; + row.innerHTML = ` +
+ + ${escapeHtml(previewText)} + +
+
+
+ + +
+ ${rangeBlock} +
+ `; + list.appendChild(row); + + // Live-update the header preview when the user retypes the friendly name. + const friendlyInput = row.querySelector('.z2m-mapping-friendly') as HTMLInputElement; + const previewEl = row.querySelector('.ha-mapping-name') as HTMLElement; + const placeholder = t('z2m_light.mapping.unassigned') || '—'; + friendlyInput.addEventListener('input', () => { + const val = friendlyInput.value.trim(); + previewEl.textContent = val || placeholder; + previewEl.classList.toggle('is-empty', !val); + }); +} + +export function removeZ2MLightMapping(btn: HTMLElement): void { + const row = btn.closest('.ha-light-mapping-row'); + if (row) row.remove(); +} + +/** + * Re-render every mapping row using the current `_editorSourceKind` layout. + * Snapshots existing values (friendly_name, ranges, brightness) so the user + * does not lose data when toggling between CSS and color_vs modes. + */ +function _rerenderMappingsForMode(): void { + const list = document.getElementById('z2m-light-mappings-list'); + if (!list) return; + const snapshot = JSON.parse(_getMappingsJSON()); + list.innerHTML = ''; + snapshot.forEach((m: any) => addZ2MLightMapping(m)); + _setMappingsModeHint(); +} + +// ── Stop-action IconSelect items ── + +function _stopActionItems() { + return [ + { value: 'none', icon: _icon(P.circleOff), label: t('z2m_light.stop_action.none'), desc: t('z2m_light.stop_action.none.desc') }, + { value: 'turn_off', icon: _icon(P.power), label: t('z2m_light.stop_action.turn_off'), desc: t('z2m_light.stop_action.turn_off.desc') }, + ]; +} + +function _ensureStopActionIconSelect(): void { + _stopActionIconSelect = ensureStopActionIconSelect( + 'z2m-light-editor-stop-action', + _stopActionItems(), + 2, + _stopActionIconSelect, + ); +} + +// ── Show / Close ── + +export async function showZ2MLightEditor(targetId: string | null = null, cloneData: any = null): Promise { + const [cssSources, mqttSources] = await Promise.all([ + colorStripSourcesCache.fetch().catch((): any[] => []), + mqttSourcesCache.fetch().catch((): any[] => []), + valueSourcesCache.fetch().catch(() => {}), + ]); + _editorCssSources = cssSources; + _editorColorValueSources = (_cachedValueSources || []).filter(_isColorValueSource); + + const isEdit = !!targetId; + const isClone = !!cloneData; + const titleKey = isEdit ? 'z2m_light.edit' : 'z2m_light.add'; + document.getElementById('z2m-light-editor-title')!.innerHTML = `${ICON_Z2M} ${t(titleKey)}`; + (document.getElementById('z2m-light-editor-id') as HTMLInputElement).value = ''; + (document.getElementById('z2m-light-editor-error') as HTMLElement).style.display = 'none'; + + // MQTT broker picker — populates from MQTTSourceStore. + const mqttSelect = document.getElementById('z2m-light-editor-mqtt-source') as HTMLSelectElement; + mqttSelect.innerHTML = `` + + mqttSources.map((s: any) => + `` + ).join(''); + + // Unified Color Source picker — combines CSS sources + colour-returning value sources. + const colorSelect = document.getElementById('z2m-light-editor-color-source') as HTMLSelectElement; + const cssOptions = cssSources.map((s: any) => + `` + ).join(''); + const colorVsOptions = _editorColorValueSources.map((s: any) => + `` + ).join(''); + colorSelect.innerHTML = `${cssOptions}${colorVsOptions}`; + + // Clear mappings + document.getElementById('z2m-light-mappings-list')!.innerHTML = ''; + + let editData: any = null; + if (isEdit) { + try { + const resp = await fetchWithAuth(`/output-targets/${targetId}`); + if (!resp.ok) throw new Error('Failed to load target'); + editData = await resp.json(); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + return; + } + } else if (isClone) { + editData = cloneData; + } + + if (editData) { + if (isEdit) (document.getElementById('z2m-light-editor-id') as HTMLInputElement).value = editData.id; + (document.getElementById('z2m-light-editor-name') as HTMLInputElement).value = editData.name || ''; + mqttSelect.value = editData.mqtt_source_id || ''; + (document.getElementById('z2m-light-editor-base-topic') as HTMLInputElement).value = editData.base_topic || 'zigbee2mqtt'; + _editorSourceKind = (editData.source_kind === 'color_vs') ? 'color_vs' : 'css'; + _editorColorVsId = editData.color_value_source_id || ''; + const editCssId = editData.color_strip_source_id || ''; + colorSelect.value = encodeColorSourceValue( + _editorSourceKind, + _editorSourceKind === 'color_vs' ? _editorColorVsId : editCssId, + ); + _widgets.applyValues(editData); + (document.getElementById('z2m-light-editor-stop-action') as HTMLSelectElement).value = editData.stop_action ?? 'none'; + _ensureStopActionIconSelect(); + if (_stopActionIconSelect) _stopActionIconSelect.setValue(editData.stop_action ?? 'none', false); + (document.getElementById('z2m-light-editor-description') as HTMLInputElement).value = editData.description || ''; + + const mappings = editData.z2m_light_mappings || []; + mappings.forEach((m: any) => addZ2MLightMapping(m)); + } else { + (document.getElementById('z2m-light-editor-name') as HTMLInputElement).value = ''; + // Default to the only configured MQTT source if there's just one; otherwise leave blank. + mqttSelect.value = mqttSources.length === 1 ? mqttSources[0].id : ''; + (document.getElementById('z2m-light-editor-base-topic') as HTMLInputElement).value = 'zigbee2mqtt'; + _editorSourceKind = 'css'; + _editorColorVsId = ''; + _widgets.applyDefaults(); + (document.getElementById('z2m-light-editor-stop-action') as HTMLSelectElement).value = 'none'; + _ensureStopActionIconSelect(); + if (_stopActionIconSelect) _stopActionIconSelect.setValue('none', false); + (document.getElementById('z2m-light-editor-description') as HTMLInputElement).value = ''; + + addZ2MLightMapping(); + } + _setMappingsModeHint(); + + // EntitySelect on the MQTT broker picker + if (_mqttSourceEntitySelect) { _mqttSourceEntitySelect.destroy(); _mqttSourceEntitySelect = null; } + _mqttSourceEntitySelect = new EntitySelect({ + target: mqttSelect, + getItems: () => (_cachedMQTTSources || []).map((s: any) => ({ + value: s.id, + label: s.name, + icon: _icon(P.palette), + desc: `${s.broker_host}:${s.broker_port}`, + })), + placeholder: t('palette.search'), + allowNone: true, + noneLabel: t('z2m_light.mqtt_source.none'), + }); + + // EntitySelect on the unified colour picker + if (_colorSourceEntitySelect) { _colorSourceEntitySelect.destroy(); _colorSourceEntitySelect = null; } + _colorSourceEntitySelect = new EntitySelect({ + target: colorSelect, + getItems: () => _buildColorSourcePickerItems(), + placeholder: t('palette.search'), + onChange: (rawValue: string) => { + // Decode "css:" or "cvs:" into source kind + id, then re-render + // mapping rows so the LED-range fields appear/disappear in step. + const { kind: newKind, id } = decodeColorSourceValue(rawValue); + _editorColorVsId = newKind === 'color_vs' ? id : ''; + if (newKind !== _editorSourceKind) { + _editorSourceKind = newKind; + _rerenderMappingsForMode(); + } else { + _setMappingsModeHint(); + } + }, + }); + + // Tags + if (_z2mLightTagsInput) { _z2mLightTagsInput.destroy(); _z2mLightTagsInput = null; } + _z2mLightTagsInput = new TagInput(document.getElementById('z2m-light-tags-container'), { placeholder: t('tags.placeholder') }); + _z2mLightTagsInput.setValue(editData?.tags || []); + + z2mLightEditorModal.open(); + z2mLightEditorModal.snapshot(); +} + +export async function closeZ2MLightEditor(): Promise { + await z2mLightEditorModal.close(); +} + +// ── Save ── + +export async function saveZ2MLightEditor(): Promise { + const targetId = (document.getElementById('z2m-light-editor-id') as HTMLInputElement).value; + const name = (document.getElementById('z2m-light-editor-name') as HTMLInputElement).value.trim(); + const mqttSourceId = (document.getElementById('z2m-light-editor-mqtt-source') as HTMLSelectElement).value; + const baseTopic = (document.getElementById('z2m-light-editor-base-topic') as HTMLInputElement).value.trim() || 'zigbee2mqtt'; + const colorSourceRaw = (document.getElementById('z2m-light-editor-color-source') as HTMLSelectElement).value; + const { kind: sourceKind, id: colorSourceId } = decodeColorSourceValue(colorSourceRaw); + const cssSourceId = sourceKind === 'css' ? colorSourceId : ''; + const colorValueSourceId = sourceKind === 'color_vs' ? colorSourceId : ''; + + const brightness = _widgets.getValue('brightness'); + const updateRate = _widgets.getValue('updateRate'); + const transition = _widgets.getValue('transition'); + const colorTolerance = _widgets.getValue('colorTolerance'); + const minBrightnessThreshold = _widgets.getValue('minBrightnessThreshold'); + const stopActionRaw = (document.getElementById('z2m-light-editor-stop-action') as HTMLSelectElement).value; + const stopAction: 'none' | 'turn_off' = stopActionRaw === 'turn_off' ? 'turn_off' : 'none'; + const description = (document.getElementById('z2m-light-editor-description') as HTMLInputElement).value.trim() || null; + + if (!name) { + z2mLightEditorModal.showError(t('z2m_light.error.name_required')); + return; + } + if (!mqttSourceId) { + z2mLightEditorModal.showError(t('z2m_light.error.mqtt_source_required') || 'MQTT broker is required'); + return; + } + if (sourceKind === 'color_vs' && !colorValueSourceId) { + z2mLightEditorModal.showError(t('z2m_light.error.color_source_required')); + return; + } + + const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.friendly_name); + if (mappings.length === 0) { + z2mLightEditorModal.showError(t('z2m_light.error.mapping_required') || 'At least one bulb mapping is required'); + return; + } + + const payload: any = { + target_type: 'z2m_light', + name, + mqtt_source_id: mqttSourceId, + source_kind: sourceKind, + color_strip_source_id: cssSourceId, + color_value_source_id: colorValueSourceId, + brightness, + z2m_light_mappings: mappings, + base_topic: baseTopic, + update_rate: updateRate, + transition, + color_tolerance: colorTolerance, + min_brightness_threshold: minBrightnessThreshold, + stop_action: stopAction, + description, + tags: _z2mLightTagsInput ? _z2mLightTagsInput.getValue() : [], + }; + + try { + const response = targetId + ? await fetchWithAuth(`/output-targets/${targetId}`, { method: 'PUT', body: JSON.stringify(payload) }) + : await fetchWithAuth('/output-targets', { method: 'POST', body: JSON.stringify(payload) }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${response.status}`); + } + showToast(targetId ? t('z2m_light.updated') : t('z2m_light.created'), 'success'); + outputTargetsCache.invalidate(); + z2mLightEditorModal.forceClose(); + if (window.loadTargetsTab) await window.loadTargetsTab(); + } catch (e: any) { + if (e.isAuth) return; + z2mLightEditorModal.showError(e.message); + } +} + +// ── Edit / Clone ── + +export async function editZ2MLightTarget(targetId: string): Promise { + await showZ2MLightEditor(targetId); +} + +export async function cloneZ2MLightTarget(targetId: string): Promise { + try { + const resp = await fetchWithAuth(`/output-targets/${targetId}`); + if (!resp.ok) throw new Error('Failed to load target'); + const data = await resp.json(); + delete data.id; + data.name = data.name + ' (copy)'; + await showZ2MLightEditor(null, data); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +// ── Card rendering ── + +export function createZ2MLightTargetCard(target: any, cssSourceMap: Record = {}, valueSourceMap: Record = {}): string { + const cssSource = cssSourceMap[target.color_strip_source_id]; + const cssId = target.color_strip_source_id; + const mappingCount = target.z2m_light_mappings?.length || 0; + const isRunning = !!target.state?.processing; + const state = target.state || {}; + + // Brightness value source + const bvsId = bindableSourceId(target.brightness); + const bvs = bvsId && valueSourceMap[bvsId] ? valueSourceMap[bvsId] : null; + + // ── Badge / meta ── + const badgeText = 'Z2M · LIGHT'; + const topicLabel = target.base_topic || 'zigbee2mqtt'; + const bulbsLabel = t('z2m_light.bulbs', { count: mappingCount }); + const rateLabel = `${target.update_rate ?? 5.0} Hz`; + const metaHtml = [escapeHtml(topicLabel), bulbsLabel, rateLabel].join(' · '); + + // ── LEDs ── + const leds: LedState[] = isRunning + ? (state.mqtt_connected === false ? ['fault'] : ['on', 'blink']) + : ['off']; + + // ── Chips ── + const chips: ModChipOpts[] = []; + const colorVsId: string = target.color_value_source_id || ''; + const colorVs = colorVsId && valueSourceMap[colorVsId] ? valueSourceMap[colorVsId] : null; + if (target.source_kind === 'color_vs') { + if (colorVs) { + chips.push({ + icon: getValueSourceIcon(colorVs.source_type), + text: colorVs.name, + title: t('z2m_light.color_source'), + onclick: `event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${colorVsId}')`, + }); + } else if (colorVsId) { + chips.push({ icon: _icon(P.palette), text: colorVsId, title: t('z2m_light.color_source') }); + } + } else if (cssSource) { + chips.push({ + icon: getColorStripIcon(cssSource.source_type), + text: cssSource.name, + title: t('targets.color_strip_source'), + onclick: `event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')`, + }); + } else if (cssId) { + chips.push({ icon: _icon(P.palette), text: cssId, title: t('targets.color_strip_source') }); + } + if (bvs) { + chips.push({ + icon: getValueSourceIcon(bvs.source_type), + text: bvs.name, + title: t('targets.brightness_vs'), + onclick: `event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')`, + }); + } else if (bindableValue(target.brightness, 1.0) < 1.0) { + chips.push({ + icon: ICON_SUN, + text: `${Math.round(bindableValue(target.brightness, 1.0) * 100)}%`, + title: t('targets.brightness'), + }); + } + + // ── Metrics (running only) ── + const metrics: ModMetricOpts[] = []; + if (isRunning) { + metrics.push({ k: 'Hz', v: '---', accent: true, title: t('targets.fps') }); + metrics.push({ k: t('device.metrics.uptime') || 'Uptime', icon: ICON_CLOCK, v: '---', title: t('device.metrics.uptime') }); + metrics.push({ + k: 'MQTT', + icon: state.mqtt_connected ? ICON_OK : ICON_WARNING, + v: '---', + title: t('z2m_light.connection_metric'), + }); + } + + const extraHtml = isRunning ? `
` : ''; + + const patchState: 'live' | 'standby' | 'offline' | 'idle' = + isRunning ? (state.mqtt_connected === false ? 'offline' : 'live') : + cssId || colorVsId ? 'standby' : 'idle'; + const patchLabel = isRunning + ? (state.mqtt_connected === false ? t('patch.disconnected') : t('patch.streaming')) + : (cssId || colorVsId) ? t('patch.standby') : t('patch.not_configured'); + + const primaryAction: ModBtnOpts = isRunning + ? { label: t('common.stop'), icon: ICON_STOP, onclick: 'void 0', title: t('device.button.stop'), variant: 'stop', dataAttrs: { 'data-action': 'stop' } } + : { label: t('common.start'), icon: ICON_START, onclick: 'void 0', title: t('device.button.start'), variant: 'go', dataAttrs: { 'data-action': 'start' } }; + + const iconActions: ModBtnOpts[] = []; + if (mappingCount > 0) { + iconActions.push({ icon: ICON_STOP_PLAIN, onclick: 'void 0', title: t('z2m_light.button.turn_off') || 'Turn Off Bulbs', variant: 'stop', dataAttrs: { 'data-action': 'turn-off' } }); + } + iconActions.push({ icon: ICON_EDIT, onclick: 'void 0', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } }); + + const targetMenuExtraItems: ModMenuItemOpts[] = []; + + const mod: ModCardOpts = { + head: { + badge: { text: badgeText }, + name: target.name, + metaHtml, + leds, + menu: { + extraItems: targetMenuExtraItems, + duplicateOnclick: `cloneZ2MLightTarget('${target.id}')`, + hideOnclick: `toggleCardHidden('z2m-light-targets','${target.id}')`, + deleteOnclick: `deleteTarget('${target.id}')`, + }, + }, + body: { + desc: target.description || undefined, + metrics: metrics.length ? metrics : undefined, + chips: chips.length ? chips : undefined, + extraHtml: extraHtml || undefined, + }, + foot: { patchState, patchLabel, primaryAction, iconActions }, + running: isRunning, + }; + + const cardHtml = wrapCard({ type: 'card', dataAttr: 'data-z2m-target-id', id: target.id, mod }); + const tags = renderTagChips(target.tags || []); + return tags ? cardHtml.replace(/<\/div>\s*$/, `${tags}`) : cardHtml; +} + +export function patchZ2MLightTargetMetrics(target: any): void { + const card = document.querySelector(`[data-z2m-target-id="${target.id}"]`); + if (!card) return; + const state = target.state || {}; + const metrics = target.metrics || {}; + + const fpsEl = card.querySelector('[data-tm="fps"]') as HTMLElement | null; + if (fpsEl) fpsEl.textContent = `${(state.fps_actual ?? 0).toFixed(1)}`; + + const uptimeEl = card.querySelector('[data-tm="uptime"]') as HTMLElement | null; + if (uptimeEl) uptimeEl.textContent = metrics.uptime_seconds ? formatUptime(metrics.uptime_seconds) : '---'; + + const mqttEl = card.querySelector('[data-tm="mqtt-status"]') as HTMLElement | null; + if (mqttEl) mqttEl.innerHTML = state.mqtt_connected ? ICON_OK : ICON_WARNING; + + const swatchesEl = card.querySelector(`[data-z2m-swatches="${target.id}"]`) as HTMLElement | null; + if (swatchesEl) swatchesEl.innerHTML = _renderEntitySwatches(state.entity_colors || {}, target.z2m_light_mappings || []); +} + +// ── Event delegation ── + +const _z2mLightActions: Record void> = { + start: (id) => _startStop(id, 'start'), + stop: (id) => _startStop(id, 'stop'), + clone: cloneZ2MLightTarget, + edit: editZ2MLightTarget, + 'turn-off': (id) => { void turnOffZ2MLightTarget(id); }, +}; + +async function _startStop(targetId: string, action: 'start' | 'stop'): Promise { + try { + const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + outputTargetsCache.invalidate(); + if (window.loadTargetsTab) await window.loadTargetsTab(); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +export async function turnOffZ2MLightTarget(targetId: string): Promise { + const confirmed = await showConfirm(t('confirm.turn_off_z2m_light') || 'Turn off mapped bulbs?'); + if (!confirmed) return; + try { + const resp = await fetchWithAuth(`/output-targets/${targetId}/z2m-light/turn-off`, { method: 'POST' }); + if (resp.ok) { + showToast(t('z2m_light.turn_off.success') || 'Bulbs turned off', 'success'); + } else { + const err = await resp.json().catch(() => ({})); + showToast(err.detail || t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', 'error'); + } + } catch (e: any) { + if (e.isAuth) return; + showToast(t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', 'error'); + } +} + +export function initZ2MLightTargetDelegation(container: HTMLElement): void { + container.addEventListener('click', (e: MouseEvent) => { + const btn = (e.target as HTMLElement).closest('[data-action]'); + if (!btn) return; + const section = btn.closest('[data-card-section="z2m-light-targets"]'); + if (!section) return; + const card = btn.closest('[data-z2m-target-id]'); + if (!card) return; + const action = btn.dataset.action; + const id = card.getAttribute('data-z2m-target-id'); + if (!action || !id) return; + const handler = _z2mLightActions[action]; + if (handler) { + e.stopPropagation(); + handler(id); + } + }); +} + +function _renderEntitySwatches(entityColors: Record, mappings: any[]): string { + if (!mappings.length) return ''; + return mappings.map(m => { + const c = entityColors[m.friendly_name]; + const bg = c ? c.hex : '#333'; + return `
+ + ${escapeHtml(m.friendly_name)} +
`; + }).join(''); +} + +// ── WebSocket color preview ── + +const _z2mLightWS: Record = {}; + +export function connectZ2MLightWS(targetId: string): void { + if (_z2mLightWS[targetId]) return; + const loc = window.location; + const wsProto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = `${wsProto}//${loc.host}/api/v1/output-targets/${targetId}/z2m-light/ws`; + + openAuthedWs(url).then((ws) => { + _z2mLightWS[targetId] = ws; + ws.onmessage = (ev) => { + try { + const data = JSON.parse(ev.data); + if (data.type === 'colors_update') _updateSwatchColors(targetId, data.colors); + } catch (err) { logError('z2m-light-targets.ws.message', err); } + }; + ws.onclose = () => { delete _z2mLightWS[targetId]; }; + ws.onerror = () => { delete _z2mLightWS[targetId]; }; + }).catch(() => { delete _z2mLightWS[targetId]; }); +} + +export function disconnectZ2MLightWS(targetId: string): void { + const ws = _z2mLightWS[targetId]; + if (ws) { ws.close(); delete _z2mLightWS[targetId]; } +} + +function _updateSwatchColors(targetId: string, colors: Record): void { + const container = document.querySelector(`[data-z2m-swatches="${targetId}"]`); + if (!container) return; + for (const [friendly, c] of Object.entries(colors)) { + const swatch = container.querySelector(`[data-entity="${friendly}"] .swatch-color`) as HTMLElement | null; + if (swatch) swatch.style.background = (c as any).hex; + } +} + +// ── Expose to global scope ── + +window.showZ2MLightEditor = showZ2MLightEditor; +window.closeZ2MLightEditor = closeZ2MLightEditor; +window.saveZ2MLightEditor = saveZ2MLightEditor; +window.editZ2MLightTarget = editZ2MLightTarget; +window.cloneZ2MLightTarget = cloneZ2MLightTarget; +window.addZ2MLightMapping = addZ2MLightMapping; +window.removeZ2MLightMapping = removeZ2MLightMapping; diff --git a/server/src/ledgrab/static/js/types.ts b/server/src/ledgrab/static/js/types.ts index 73ccef7..ebffea2 100644 --- a/server/src/ledgrab/static/js/types.ts +++ b/server/src/ledgrab/static/js/types.ts @@ -90,7 +90,7 @@ export interface Device { // ── Output Target ───────────────────────────────────────────── -export type TargetType = 'led' | 'ha_light'; +export type TargetType = 'led' | 'ha_light' | 'z2m_light'; export interface HALightMapping { entity_id: string; @@ -99,6 +99,13 @@ export interface HALightMapping { brightness_scale: BindableFloat; } +export interface Z2MLightMapping { + friendly_name: string; + led_start: number; + led_end: number; + brightness_scale: BindableFloat; +} + interface OutputTargetBase { id: string; name: string; @@ -147,7 +154,23 @@ export interface HALightOutputTarget extends OutputTargetBase { min_brightness_threshold?: BindableFloat; } -export type OutputTarget = LedOutputTarget | HALightOutputTarget; +export interface Z2MLightOutputTarget extends OutputTargetBase { + target_type: 'z2m_light'; + mqtt_source_id: string; + source_kind: HALightSourceKind; + color_strip_source_id: string; + color_value_source_id?: string; + brightness?: BindableFloat; + z2m_light_mappings?: Z2MLightMapping[]; + base_topic: string; + update_rate?: BindableFloat; + transition?: BindableFloat; + color_tolerance?: BindableFloat; + min_brightness_threshold?: BindableFloat; + stop_action?: 'none' | 'turn_off'; +} + +export type OutputTarget = LedOutputTarget | HALightOutputTarget | Z2MLightOutputTarget; // ── Color Strip Source ──────────────────────────────────────── @@ -281,7 +304,9 @@ export interface ColorStripSource { app_filter_mode?: string; app_filter_list?: string[]; os_listener?: boolean; + sound_asset_id?: string | null; sound_volume?: BindableFloat; + app_sounds?: Record; // Daylight use_real_time?: boolean; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 0cc7169..1c39bb5 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -360,6 +360,36 @@ "device.health.offline": "Offline", "device.health.streaming_unreachable": "Unreachable during streaming", "device.health.checking": "Checking...", + "patch.streaming": "STREAMING", + "patch.standby": "STANDBY", + "patch.online": "ONLINE", + "patch.offline": "OFFLINE", + "patch.disconnected": "DISCONNECTED", + "patch.not_configured": "NOT CONFIGURED", + "patch.unreachable": "UNREACHABLE", + "patch.checking": "CHECKING", + "patch.ticking": "TICKING", + "patch.paused": "PAUSED", + "patch.patched": "PATCHED", + "patch.preset": "PRESET", + "patch.source": "SOURCE", + "patch.template": "TEMPLATE", + "patch.pipeline": "PIPELINE", + "patch.strip": "STRIP", + "patch.value": "VALUE", + "patch.ready": "READY", + "patch.polling": "POLLING", + "perf.idle": "idle", + "perf.offline": "offline", + "perf.online": "online", + "perf.no_devices": "no devices", + "perf.all_online": "all online", + "perf.online_count": "{count} online", + "perf.offline_count": "{count} offline", + "perf.total_bytes": "{bytes} total", + "perf.max_ms": "max {ms}ms", + "perf.targets_count.one": "{count} target", + "perf.targets_count.other": "{count} targets", "device.last_seen.label": "Last seen", "device.last_seen.just_now": "just now", "device.last_seen.seconds": "%ds ago", @@ -562,6 +592,9 @@ "common.undo": "Undo", "common.cancel": "Cancel", "common.apply": "Apply", + "common.start": "START", + "common.stop": "STOP", + "common.led_preview": "LED Preview", "device.icon.eyebrow": "Card icon", "device.icon.title": "Choose an icon", "device.icon.for": "for", @@ -1126,7 +1159,9 @@ "automations.rule.system_idle.idle_minutes": "Idle Timeout (minutes):", "automations.rule.system_idle.mode": "Trigger Mode:", "automations.rule.system_idle.when_idle": "When idle", + "automations.rule.system_idle.when_idle.desc": "Fires once the user has been idle past the timeout", "automations.rule.system_idle.when_active": "When active", + "automations.rule.system_idle.when_active.desc": "Fires while the user is actively using the system", "automations.rule.display_state": "Display State", "automations.rule.display_state.desc": "Monitor on/off", "automations.rule.display_state.state": "Monitor State:", @@ -1189,6 +1224,7 @@ "scenes.targets.hint": "Select which targets to include in this scene snapshot", "scenes.targets.add": "Add Target", "scenes.targets.search_placeholder": "Search targets...", + "scenes.targets.empty": "No targets selected", "scenes.capture": "Capture", "scenes.activate": "Activate scene", "scenes.recapture": "Recapture current state", @@ -1526,7 +1562,7 @@ "color_strip.processed.error.no_input": "Please select an input source", "color_strip.composite.layers": "Layers:", "color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.", - "color_strip.composite.add_layer": "+ Add Layer", + "color_strip.composite.add_layer": "Add Layer", "color_strip.composite.source": "Source", "color_strip.composite.blend_mode": "Blend", "color_strip.composite.blend_mode.normal": "Normal", @@ -2314,6 +2350,7 @@ "ha_light.created": "HA light target created", "ha_light.updated": "HA light target updated", "ha_light.mapping.select_entity": "Select a light entity...", + "ha_light.mapping.unassigned": "— No entity selected", "ha_light.mapping.search_entity": "Search light entities...", "ha_light.stop_action": "On Stop:", "ha_light.stop_action.hint": "What to do with the mapped lights when this target stops streaming.", @@ -2323,7 +2360,69 @@ "ha_light.stop_action.turn_off.desc": "Switch all mapped lights off", "ha_light.stop_action.restore": "Restore", "ha_light.stop_action.restore.desc": "Revert to state captured at start", + "ha_light.button.turn_off": "Turn Off Lights", + "ha_light.turn_off.success": "Lights turned off", + "ha_light.turn_off.failed": "Failed to turn off lights", + "confirm.turn_off_ha_light": "Turn off all mapped lights?", + "ha_light.connection_tooltip": "HA Connection", + "ha_light.connection_metric": "Home Assistant connection", + "ha_light.lights.one": "{count} light", + "ha_light.lights.other": "{count} lights", "section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.", + "z2m_light.section.title": "Zigbee2MQTT", + "z2m_light.section.targets": "Z2M Light Targets", + "z2m_light.add": "Add Z2M Light Target", + "z2m_light.edit": "Edit Z2M Light Target", + "z2m_light.name": "Name:", + "z2m_light.name.placeholder": "Living Room Bulbs", + "z2m_light.description": "Description (optional):", + "z2m_light.mqtt_source": "MQTT Broker:", + "z2m_light.mqtt_source.hint": "Pick the MQTT source (broker) that Zigbee2MQTT is connected to. Manage brokers under MQTT Sources.", + "z2m_light.mqtt_source.none": "— Pick an MQTT broker", + "z2m_light.base_topic": "Z2M Base Topic:", + "z2m_light.base_topic.hint": "Override only if your Zigbee2MQTT instance uses a non-default mqtt.base_topic.", + "z2m_light.color_source": "Color Source:", + "z2m_light.color_source.hint": "Pick a Color Strip Source (per-bulb LED ranges) or a Color Value Source (one colour broadcast to every bulb).", + "z2m_light.color_source.css": "Color strip", + "z2m_light.color_source.color_vs": "Color value source", + "z2m_light.update_rate": "Update Rate:", + "z2m_light.update_rate.hint": "How often to publish colour updates to Z2M (0.5–10 Hz). Zigbee mesh tolerates ~5–10 Hz per bulb; higher values risk drops.", + "z2m_light.transition": "Transition:", + "z2m_light.transition.hint": "Smooth fade duration between colours, in seconds (Z2M transition parameter).", + "z2m_light.brightness": "Brightness:", + "z2m_light.color_tolerance": "Color Tolerance:", + "z2m_light.color_tolerance.hint": "Skip publishes when the RGB delta is below this threshold. Reduces Zigbee mesh traffic for near-static scenes.", + "z2m_light.min_brightness_threshold": "Min Brightness Threshold:", + "z2m_light.min_brightness_threshold.hint": "Effective output brightness below this value turns bulbs off completely (0 = disabled).", + "z2m_light.mappings": "Bulb Mappings:", + "z2m_light.mappings.hint": "Map LED ranges to Z2M friendly names. Each mapping averages the LED segment to a single colour.", + "z2m_light.mappings.color_vs_hint": "All listed bulbs will receive the same colour from the selected Color Value Source.", + "z2m_light.mappings.add": "Add Mapping", + "z2m_light.mapping.friendly_name": "Friendly Name:", + "z2m_light.mapping.led_start": "LED Start:", + "z2m_light.mapping.led_end": "LED End (-1=last):", + "z2m_light.mapping.brightness": "Brightness Scale:", + "z2m_light.mapping.unassigned": "— No bulb selected", + "z2m_light.stop_action": "On Stop:", + "z2m_light.stop_action.hint": "What to do with the mapped bulbs when this target stops streaming.", + "z2m_light.stop_action.none": "None", + "z2m_light.stop_action.none.desc": "Leave bulbs as-is", + "z2m_light.stop_action.turn_off": "Turn Off", + "z2m_light.stop_action.turn_off.desc": "Switch all mapped bulbs off", + "z2m_light.connection_metric": "Zigbee2MQTT broker connection", + "z2m_light.error.name_required": "Name is required", + "z2m_light.error.mqtt_source_required": "MQTT broker is required", + "z2m_light.error.color_source_required": "Color value source is required when broadcasting a single colour", + "z2m_light.error.mapping_required": "At least one bulb mapping is required", + "z2m_light.created": "Z2M light target created", + "z2m_light.updated": "Z2M light target updated", + "z2m_light.button.turn_off": "Turn Off Bulbs", + "z2m_light.turn_off.success": "Bulbs turned off", + "z2m_light.turn_off.failed": "Failed to turn off bulbs", + "confirm.turn_off_z2m_light": "Turn off all mapped bulbs?", + "z2m_light.bulbs.one": "{count} bulb", + "z2m_light.bulbs.other": "{count} bulbs", + "section.empty.z2m_light_targets": "No Z2M light targets yet. Click + to add one.", "automations.rule.home_assistant": "Home Assistant", "automations.rule.home_assistant.desc": "HA entity state", "automations.rule.home_assistant.ha_source": "HA Source:", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 661472c..13b00ab 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -14,12 +14,49 @@ "ha_light.stop_action.turn_off.desc": "Выключить все привязанные лампы", "ha_light.stop_action.restore": "Восстановить", "ha_light.stop_action.restore.desc": "Вернуть состояние на момент запуска", + "ha_light.button.turn_off": "Выключить лампы", + "ha_light.turn_off.success": "Лампы выключены", + "ha_light.turn_off.failed": "Не удалось выключить лампы", + "confirm.turn_off_ha_light": "Выключить все привязанные лампы?", + "ha_light.connection_tooltip": "Подключение HA", + "ha_light.connection_metric": "Подключение Home Assistant", + "ha_light.lights.one": "{count} лампа", + "ha_light.lights.few": "{count} лампы", + "ha_light.lights.many": "{count} ламп", "ha_light.color_source": "Источник цвета:", "ha_light.color_source.hint": "Выберите Источник полосы цвета (диапазоны LED для каждой лампы) или Источник значения цвета (один цвет на все лампы).", "ha_light.color_source.css": "Полоса цвета", "ha_light.color_source.color_vs": "Источник значения цвета", "ha_light.mappings.color_vs_hint": "Все указанные лампы получат один и тот же цвет от выбранного Источника значения цвета.", + "ha_light.mapping.unassigned": "— Сущность не выбрана", "ha_light.error.color_source_required": "Источник значения цвета обязателен в режиме одного цвета", + "ha_light.section.title": "Home Assistant", + "ha_light.section.targets": "Цели освещения", + "ha_light.add": "Добавить HA-цель освещения", + "ha_light.edit": "Редактировать HA-цель освещения", + "ha_light.created": "HA-цель освещения создана", + "ha_light.updated": "HA-цель освещения обновлена", + "ha_light.name": "Имя:", + "ha_light.name.placeholder": "Лампы гостиной", + "ha_light.description": "Описание (опционально):", + "ha_light.ha_source": "HA-подключение:", + "ha_light.css_source": "Источник полосы цвета:", + "ha_light.update_rate": "Частота обновления:", + "ha_light.update_rate.hint": "Как часто отправлять обновления цвета лампам HA (0.5-5.0 Гц). Меньшие значения безопаснее для производительности HA.", + "ha_light.transition": "Переход:", + "ha_light.transition.hint": "Длительность плавного перехода между цветами (параметр HA transition).", + "ha_light.mappings": "Привязки ламп:", + "ha_light.mappings.add": "Добавить привязку", + "ha_light.mappings.hint": "Сопоставьте диапазоны LED с сущностями ламп HA. Каждая привязка усредняет LED-сегмент до одного цвета.", + "ha_light.mapping.entity_id": "ID сущности:", + "ha_light.mapping.led_start": "Начало LED:", + "ha_light.mapping.led_end": "Конец LED (-1=последний):", + "ha_light.mapping.brightness": "Множитель яркости:", + "ha_light.mapping.search_entity": "Поиск сущностей-ламп...", + "ha_light.mapping.select_entity": "Выберите сущность-лампу...", + "ha_light.error.name_required": "Требуется имя", + "ha_light.error.ha_source_required": "Требуется HA-подключение", + "ha_light.lights.other": "{count} ламп", "app.version": "Версия:", "app.api_docs": "Документация API", "app.connection_lost": "Сервер недоступен", @@ -378,6 +415,37 @@ "device.health.offline": "Недоступен", "device.health.streaming_unreachable": "Недоступен во время стриминга", "device.health.checking": "Проверка...", + "patch.streaming": "ТРАНСЛЯЦИЯ", + "patch.standby": "ОЖИДАНИЕ", + "patch.online": "ОНЛАЙН", + "patch.offline": "ОФЛАЙН", + "patch.disconnected": "ОТКЛЮЧЕНО", + "patch.not_configured": "НЕ НАСТРОЕНО", + "patch.unreachable": "НЕДОСТУПЕН", + "patch.checking": "ПРОВЕРКА", + "patch.ticking": "ИДЁТ", + "patch.paused": "ПАУЗА", + "patch.patched": "ПОДКЛЮЧЕНО", + "patch.preset": "ПРЕСЕТ", + "patch.source": "ИСТОЧНИК", + "patch.template": "ШАБЛОН", + "patch.pipeline": "КОНВЕЙЕР", + "patch.strip": "ПОЛОСА", + "patch.value": "ЗНАЧЕНИЕ", + "patch.ready": "ГОТОВ", + "patch.polling": "ОПРОС", + "perf.idle": "простой", + "perf.offline": "офлайн", + "perf.online": "онлайн", + "perf.no_devices": "нет устройств", + "perf.all_online": "все онлайн", + "perf.online_count": "{count} онлайн", + "perf.offline_count": "{count} офлайн", + "perf.total_bytes": "всего {bytes}", + "perf.max_ms": "макс {ms}мс", + "perf.targets_count.one": "{count} цель", + "perf.targets_count.few": "{count} цели", + "perf.targets_count.many": "{count} целей", "device.last_seen.label": "Последний раз", "device.last_seen.just_now": "только что", "device.last_seen.seconds": "%d с назад", @@ -580,6 +648,9 @@ "common.undo": "Отменить", "common.cancel": "Отмена", "common.apply": "Применить", + "common.start": "ПУСК", + "common.stop": "СТОП", + "common.led_preview": "Превью LED", "device.icon.eyebrow": "Иконка карточки", "device.icon.title": "Выберите иконку", "device.icon.for": "для", @@ -1121,7 +1192,9 @@ "automations.rule.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):", "automations.rule.system_idle.mode": "Режим срабатывания:", "automations.rule.system_idle.when_idle": "При бездействии", + "automations.rule.system_idle.when_idle.desc": "Срабатывает, когда пользователь не активен дольше тайм-аута", "automations.rule.system_idle.when_active": "При активности", + "automations.rule.system_idle.when_active.desc": "Срабатывает, пока пользователь активно работает с системой", "automations.rule.display_state": "Состояние дисплея", "automations.rule.display_state.desc": "Монитор вкл/выкл", "automations.rule.display_state.state": "Состояние монитора:", @@ -1184,6 +1257,7 @@ "scenes.targets.hint": "Выберите какие цели включить в снимок сцены", "scenes.targets.add": "Добавить цель", "scenes.targets.search_placeholder": "Поиск целей...", + "scenes.targets.empty": "Цели не выбраны", "scenes.capture": "Захват", "scenes.activate": "Активировать сцену", "scenes.recapture": "Перезахватить текущее состояние", @@ -1469,7 +1543,7 @@ "color_strip.processed.error.no_input": "Выберите входной источник", "color_strip.composite.layers": "Слои:", "color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.", - "color_strip.composite.add_layer": "+ Добавить слой", + "color_strip.composite.add_layer": "Добавить слой", "color_strip.composite.source": "Источник", "color_strip.composite.blend_mode": "Смешивание", "color_strip.composite.blend_mode.normal": "Обычное", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 70662cd..691f65a 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -14,12 +14,47 @@ "ha_light.stop_action.turn_off.desc": "关闭所有映射的灯", "ha_light.stop_action.restore": "恢复", "ha_light.stop_action.restore.desc": "恢复到启动时捕获的状态", + "ha_light.button.turn_off": "关闭灯具", + "ha_light.turn_off.success": "灯具已关闭", + "ha_light.turn_off.failed": "关闭灯具失败", + "confirm.turn_off_ha_light": "关闭所有映射的灯具?", + "ha_light.connection_tooltip": "HA 连接", + "ha_light.connection_metric": "Home Assistant 连接", + "ha_light.lights.one": "{count} 盏灯", + "ha_light.lights.other": "{count} 盏灯", "ha_light.color_source": "颜色源:", "ha_light.color_source.hint": "选择颜色条源(每个灯具的 LED 范围)或颜色值源(一种颜色广播到所有灯具)。", "ha_light.color_source.css": "颜色条", "ha_light.color_source.color_vs": "颜色值源", "ha_light.mappings.color_vs_hint": "所有列出的灯具都将接收来自所选颜色值源的相同颜色。", + "ha_light.mapping.unassigned": "— 未选择实体", "ha_light.error.color_source_required": "广播单一颜色时必须选择颜色值源", + "ha_light.section.title": "Home Assistant", + "ha_light.section.targets": "灯光目标", + "ha_light.add": "添加 HA 灯光目标", + "ha_light.edit": "编辑 HA 灯光目标", + "ha_light.created": "HA 灯光目标已创建", + "ha_light.updated": "HA 灯光目标已更新", + "ha_light.name": "名称:", + "ha_light.name.placeholder": "客厅灯光", + "ha_light.description": "描述(可选):", + "ha_light.ha_source": "HA 连接:", + "ha_light.css_source": "颜色条源:", + "ha_light.update_rate": "更新频率:", + "ha_light.update_rate.hint": "向 HA 灯光发送颜色更新的频率(0.5-5.0 Hz)。较低值对 HA 性能更安全。", + "ha_light.transition": "过渡:", + "ha_light.transition.hint": "颜色之间的平滑过渡时长(HA transition 参数)。", + "ha_light.mappings": "灯光映射:", + "ha_light.mappings.add": "添加映射", + "ha_light.mappings.hint": "将 LED 范围映射到 HA 灯光实体。每个映射将 LED 段平均为单一颜色。", + "ha_light.mapping.entity_id": "实体 ID:", + "ha_light.mapping.led_start": "LED 开始:", + "ha_light.mapping.led_end": "LED 结束(-1=最后):", + "ha_light.mapping.brightness": "亮度比例:", + "ha_light.mapping.search_entity": "搜索灯光实体...", + "ha_light.mapping.select_entity": "选择一个灯光实体...", + "ha_light.error.name_required": "需要名称", + "ha_light.error.ha_source_required": "需要 HA 连接", "app.version": "版本:", "app.api_docs": "API 文档", "app.connection_lost": "服务器不可达", @@ -378,6 +413,36 @@ "device.health.offline": "离线", "device.health.streaming_unreachable": "流传输期间不可达", "device.health.checking": "检测中...", + "patch.streaming": "传输中", + "patch.standby": "待机", + "patch.online": "在线", + "patch.offline": "离线", + "patch.disconnected": "已断开", + "patch.not_configured": "未配置", + "patch.unreachable": "无法访问", + "patch.checking": "检测中", + "patch.ticking": "运行中", + "patch.paused": "已暂停", + "patch.patched": "已连接", + "patch.preset": "预设", + "patch.source": "源", + "patch.template": "模板", + "patch.pipeline": "管线", + "patch.strip": "色带", + "patch.value": "值", + "patch.ready": "就绪", + "patch.polling": "轮询中", + "perf.idle": "空闲", + "perf.offline": "离线", + "perf.online": "在线", + "perf.no_devices": "无设备", + "perf.all_online": "全部在线", + "perf.online_count": "{count} 在线", + "perf.offline_count": "{count} 离线", + "perf.total_bytes": "共 {bytes}", + "perf.max_ms": "最大 {ms}毫秒", + "perf.targets_count.one": "{count} 个目标", + "perf.targets_count.other": "{count} 个目标", "device.last_seen.label": "最近检测", "device.last_seen.just_now": "刚刚", "device.last_seen.seconds": "%d秒前", @@ -580,6 +645,9 @@ "common.undo": "撤销", "common.cancel": "取消", "common.apply": "应用", + "common.start": "启动", + "common.stop": "停止", + "common.led_preview": "LED 预览", "device.icon.eyebrow": "卡片图标", "device.icon.title": "选择图标", "device.icon.for": "用于", @@ -1121,7 +1189,9 @@ "automations.rule.system_idle.idle_minutes": "空闲超时(分钟):", "automations.rule.system_idle.mode": "触发模式:", "automations.rule.system_idle.when_idle": "空闲时", + "automations.rule.system_idle.when_idle.desc": "当用户空闲时间超过超时阈值后触发", "automations.rule.system_idle.when_active": "活跃时", + "automations.rule.system_idle.when_active.desc": "当用户正在使用系统时触发", "automations.rule.display_state": "显示器状态", "automations.rule.display_state.desc": "显示器开/关", "automations.rule.display_state.state": "显示器状态:", @@ -1184,6 +1254,7 @@ "scenes.targets.hint": "选择要包含在此场景快照中的目标", "scenes.targets.add": "添加目标", "scenes.targets.search_placeholder": "搜索目标...", + "scenes.targets.empty": "未选择目标", "scenes.capture": "捕获", "scenes.activate": "激活场景", "scenes.recapture": "重新捕获当前状态", @@ -1469,7 +1540,7 @@ "color_strip.processed.error.no_input": "请选择输入源", "color_strip.composite.layers": "图层:", "color_strip.composite.layers.hint": "叠加多个色带源。第一个图层在底部,最后一个在顶部。每个图层可以有自己的混合模式和不透明度。", - "color_strip.composite.add_layer": "+ 添加图层", + "color_strip.composite.add_layer": "添加图层", "color_strip.composite.source": "源", "color_strip.composite.blend_mode": "混合", "color_strip.composite.blend_mode.normal": "正常", diff --git a/server/src/ledgrab/storage/bindable.py b/server/src/ledgrab/storage/bindable.py index ed43dc0..9c626e9 100644 --- a/server/src/ledgrab/storage/bindable.py +++ b/server/src/ledgrab/storage/bindable.py @@ -80,18 +80,18 @@ class BindableFloat: """Return a new BindableFloat from an update payload. Accepts: - • plain number → sets value, clears source_id + • plain number → sets value, clears source_id (unbinds) • dict → sets both • None → returns self unchanged """ if raw is None: return self if isinstance(raw, (int, float)): - return BindableFloat(value=float(raw), source_id=self.source_id) + return BindableFloat(value=float(raw), source_id="") if isinstance(raw, dict): return BindableFloat( value=float(raw.get("value", self.value)), - source_id=raw.get("source_id", self.source_id), + source_id=raw.get("source_id", self.source_id) or "", ) return self @@ -137,11 +137,17 @@ class BindableColor: return cls(color=list(default)) def apply_update(self, raw) -> "BindableColor": - """Return a new BindableColor from an update payload.""" + """Return a new BindableColor from an update payload. + + Accepts: + • plain ``[R,G,B]`` list → sets color, clears source_id (unbinds) + • dict → sets both + • None → returns self unchanged + """ if raw is None: return self if isinstance(raw, list) and len(raw) == 3: - return BindableColor(color=list(raw), source_id=self.source_id) + return BindableColor(color=list(raw), source_id="") if isinstance(raw, dict): color = raw.get("color", self.color) if isinstance(color, list) and len(color) == 3: @@ -150,7 +156,7 @@ class BindableColor: color = list(self.color) return BindableColor( color=color, - source_id=raw.get("source_id", self.source_id), + source_id=raw.get("source_id", self.source_id) or "", ) return self diff --git a/server/src/ledgrab/storage/device_store.py b/server/src/ledgrab/storage/device_store.py index 7a3e37e..3013056 100644 --- a/server/src/ledgrab/storage/device_store.py +++ b/server/src/ledgrab/storage/device_store.py @@ -58,6 +58,8 @@ class Device: # BLE controller fields (SP110E / Triones / Zengge / Govee) ble_family: str = "", ble_govee_key: str = "", + # MQTT device fields (references an MQTTSource for the broker connection) + mqtt_source_id: str = "", # Default color strip processing template default_css_processing_template_id: str = "", # Group device fields @@ -96,6 +98,7 @@ class Device: self.gamesense_device_type = gamesense_device_type self.ble_family = ble_family self.ble_govee_key = ble_govee_key + self.mqtt_source_id = mqtt_source_id self.default_css_processing_template_id = default_css_processing_template_id self.group_device_ids = group_device_ids or [] self.group_mode = group_mode @@ -188,7 +191,7 @@ class Device: if dt == "demo": return DemoConfig(**base, send_latency_ms=self.send_latency_ms) if dt == "mqtt": - return MQTTConfig(**base) + return MQTTConfig(**base, mqtt_source_id=self.mqtt_source_id) if dt == "ws": return WSConfig(**base) if dt == "usbhid": @@ -249,6 +252,8 @@ class Device: d["ble_family"] = self.ble_family if self.ble_govee_key: d["ble_govee_key"] = self.ble_govee_key + if self.mqtt_source_id: + d["mqtt_source_id"] = self.mqtt_source_id if self.default_css_processing_template_id: d["default_css_processing_template_id"] = self.default_css_processing_template_id if self.group_device_ids: @@ -292,6 +297,7 @@ class Device: gamesense_device_type=data.get("gamesense_device_type", "keyboard"), ble_family=data.get("ble_family", ""), ble_govee_key=data.get("ble_govee_key", ""), + mqtt_source_id=data.get("mqtt_source_id", ""), default_css_processing_template_id=data.get("default_css_processing_template_id", ""), group_device_ids=data.get("group_device_ids", []), group_mode=data.get("group_mode", "sequence"), @@ -335,6 +341,7 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset( "gamesense_device_type", "ble_family", "ble_govee_key", + "mqtt_source_id", "default_css_processing_template_id", "group_device_ids", "group_mode", @@ -387,6 +394,7 @@ class DeviceStore(BaseSqliteStore[Device]): gamesense_device_type: str = "keyboard", ble_family: str = "", ble_govee_key: str = "", + mqtt_source_id: str = "", group_device_ids: Optional[List[str]] = None, group_mode: str = "sequence", ) -> Device: @@ -426,6 +434,7 @@ class DeviceStore(BaseSqliteStore[Device]): gamesense_device_type=gamesense_device_type, ble_family=ble_family, ble_govee_key=ble_govee_key, + mqtt_source_id=mqtt_source_id, group_device_ids=group_device_ids or [], group_mode=group_mode, ) diff --git a/server/src/ledgrab/storage/ha_light_output_target.py b/server/src/ledgrab/storage/ha_light_output_target.py index bae9bbc..df34d4d 100644 --- a/server/src/ledgrab/storage/ha_light_output_target.py +++ b/server/src/ledgrab/storage/ha_light_output_target.py @@ -41,7 +41,7 @@ VALID_SOURCE_KINDS = ("css", "color_vs") @dataclass -class HALightOutputTarget(OutputTarget): +class HALightOutputTarget(OutputTarget, type_key="ha_light"): """Output target that casts LED colors to Home Assistant lights via service calls. Two source modes are supported: diff --git a/server/src/ledgrab/storage/output_target.py b/server/src/ledgrab/storage/output_target.py index 100de8c..babb422 100644 --- a/server/src/ledgrab/storage/output_target.py +++ b/server/src/ledgrab/storage/output_target.py @@ -2,16 +2,31 @@ from dataclasses import dataclass, field from datetime import datetime -from typing import List, Optional +from typing import ClassVar, Dict, List, Optional, Type @dataclass class OutputTarget: - """Base class for output targets.""" + """Base class for output targets. + + Subclasses register themselves into ``_registry`` via the + ``type_key="..."`` class-header argument; :meth:`from_dict` + uses that registry to dispatch deserialization without a + per-type if/elif chain. The :attr:`target_type` instance + field stays as the persisted discriminator. + """ + + # Discriminator-keyed registry of concrete subclasses. Populated + # automatically via ``__init_subclass__``. Requires the subclass + # modules to be imported at least once before ``from_dict`` runs; + # ``output_target_store`` imports all three at module load, which + # is sufficient for the live app and tests. + _registry: ClassVar[Dict[str, Type["OutputTarget"]]] = {} + _type_key: ClassVar[str] = "" id: str name: str - target_type: str # "led", "ha_light" + target_type: str # "led", "ha_light", "z2m_light" created_at: datetime updated_at: datetime description: Optional[str] = None @@ -22,6 +37,12 @@ class OutputTarget: icon: str = "" icon_color: str = "" + def __init_subclass__(cls, *, type_key: str = "", **kwargs) -> None: + super().__init_subclass__(**kwargs) + if type_key: + cls._type_key = type_key + OutputTarget._registry[type_key] = cls + def register_with_manager(self, manager) -> None: """Register this target with the processor manager. Subclasses override.""" pass @@ -74,14 +95,15 @@ class OutputTarget: @classmethod def from_dict(cls, data: dict) -> "OutputTarget": - """Create from dictionary, dispatching to the correct subclass.""" + """Create from dictionary, dispatching to the correct subclass via the registry.""" target_type = data.get("target_type", "led") - if target_type == "led": - from ledgrab.storage.wled_output_target import WledOutputTarget - - return WledOutputTarget.from_dict(data) - if target_type == "ha_light": - from ledgrab.storage.ha_light_output_target import HALightOutputTarget - - return HALightOutputTarget.from_dict(data) - raise ValueError(f"Unknown target type: {target_type}") + # When called on a concrete subclass (e.g. ``WledOutputTarget.from_dict``), + # respect that — subclasses override this method to actually build themselves. + if cls is not OutputTarget: + raise NotImplementedError( + f"{cls.__name__}.from_dict must be implemented on the subclass" + ) + impl = OutputTarget._registry.get(target_type) + if impl is None: + raise ValueError(f"Unknown target type: {target_type}") + return impl.from_dict(data) diff --git a/server/src/ledgrab/storage/output_target_store.py b/server/src/ledgrab/storage/output_target_store.py index a70193f..9c070b0 100644 --- a/server/src/ledgrab/storage/output_target_store.py +++ b/server/src/ledgrab/storage/output_target_store.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import List, Optional +from typing import Any, List, Optional from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.bindable import BindableFloat @@ -13,12 +13,21 @@ from ledgrab.storage.ha_light_output_target import ( HALightMapping, HALightOutputTarget, ) +from ledgrab.storage.z2m_light_output_target import ( + DEFAULT_Z2M_BASE_TOPIC, + Z2MLightMapping, + Z2MLightOutputTarget, +) from ledgrab.utils import get_logger logger = get_logger(__name__) DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds +_VALID_HA_SOURCE_KINDS = ("css", "color_vs") +_VALID_HA_STOP_ACTIONS = ("none", "turn_off", "restore") +_VALID_Z2M_STOP_ACTIONS = ("none", "turn_off") + class OutputTargetStore(BaseSqliteStore[OutputTarget]): """Persistent storage for output targets.""" @@ -34,6 +43,177 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): get_target = BaseSqliteStore.get delete_target = BaseSqliteStore.delete + # ---- shared helpers ------------------------------------------------- + + def _check_unique_name(self, name: str) -> None: + for target in self._items.values(): + if target.name == name: + raise ValueError(f"Output target with name '{name}' already exists") + + def _new_id_and_now(self) -> tuple[str, datetime]: + return f"pt_{uuid.uuid4().hex[:8]}", datetime.now(timezone.utc) + + @staticmethod + def _resolve_brightness(brightness: Any, brightness_value_source_id: str = "") -> BindableFloat: + if isinstance(brightness, BindableFloat): + return brightness + if brightness is not None: + return BindableFloat.from_raw(brightness, default=1.0) + if brightness_value_source_id: + return BindableFloat(1.0, source_id=brightness_value_source_id) + return BindableFloat(1.0) + + @staticmethod + def _resolve_transition(transition: Any, default: float) -> BindableFloat: + if isinstance(transition, BindableFloat): + return transition + if transition is not None: + return BindableFloat.from_raw(transition, default=default) + return BindableFloat(default) + + def _finalize( + self, target: OutputTarget, *, tags: Optional[List[str]], log_type: str + ) -> OutputTarget: + target.tags = tags or [] + self._items[target.id] = target + self._save_item(target.id, target) + logger.info(f"Created output target: {target.name} ({target.id}, type={log_type})") + return target + + # ---- typed factory methods ----------------------------------------- + + def create_wled_target( + self, + name: str, + *, + device_id: str = "", + color_strip_source_id: str = "", + brightness: Any = None, + fps: Any = 30, + keepalive_interval: float = 1.0, + state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, + min_brightness_threshold: Any = 0, + adaptive_fps: bool = False, + protocol: str = "ddp", + description: Optional[str] = None, + tags: Optional[List[str]] = None, + # legacy compat + brightness_value_source_id: str = "", + ) -> WledOutputTarget: + """Create a WLED/LED output target.""" + self._check_unique_name(name) + target_id, now = self._new_id_and_now() + target = WledOutputTarget( + id=target_id, + name=name, + target_type="led", + device_id=device_id, + color_strip_source_id=color_strip_source_id, + brightness=self._resolve_brightness(brightness, brightness_value_source_id), + fps=BindableFloat.from_raw(fps, default=30.0), + keepalive_interval=keepalive_interval, + state_check_interval=state_check_interval, + min_brightness_threshold=BindableFloat.from_raw(min_brightness_threshold, default=0.0), + adaptive_fps=adaptive_fps, + protocol=protocol, + description=description, + created_at=now, + updated_at=now, + ) + return self._finalize(target, tags=tags, log_type="led") + + def create_ha_light_target( + self, + name: str, + *, + ha_source_id: str = "", + source_kind: str = "css", + color_strip_source_id: str = "", + color_value_source_id: str = "", + brightness: Any = None, + ha_light_mappings: Optional[List[HALightMapping]] = None, + update_rate: Any = 2.0, + transition: Any = None, + min_brightness_threshold: Any = 0, + color_tolerance: Any = 5, + stop_action: str = "none", + description: Optional[str] = None, + tags: Optional[List[str]] = None, + # legacy compat + brightness_value_source_id: str = "", + ) -> HALightOutputTarget: + """Create a Home Assistant light output target.""" + self._check_unique_name(name) + target_id, now = self._new_id_and_now() + target = HALightOutputTarget( + id=target_id, + name=name, + target_type="ha_light", + ha_source_id=ha_source_id, + source_kind=source_kind if source_kind in _VALID_HA_SOURCE_KINDS else "css", + color_strip_source_id=color_strip_source_id, + color_value_source_id=color_value_source_id, + brightness=self._resolve_brightness(brightness, brightness_value_source_id), + light_mappings=ha_light_mappings or [], + update_rate=BindableFloat.from_raw(update_rate, default=2.0), + transition=self._resolve_transition(transition, default=0.5), + min_brightness_threshold=BindableFloat.from_raw(min_brightness_threshold, default=0.0), + color_tolerance=BindableFloat.from_raw(color_tolerance, default=5.0), + stop_action=stop_action if stop_action in _VALID_HA_STOP_ACTIONS else "none", + description=description, + created_at=now, + updated_at=now, + ) + return self._finalize(target, tags=tags, log_type="ha_light") + + def create_z2m_light_target( + self, + name: str, + *, + mqtt_source_id: str = "", + source_kind: str = "css", + color_strip_source_id: str = "", + color_value_source_id: str = "", + brightness: Any = None, + z2m_light_mappings: Optional[List[Z2MLightMapping]] = None, + base_topic: str = DEFAULT_Z2M_BASE_TOPIC, + update_rate: Any = 5.0, + transition: Any = None, + min_brightness_threshold: Any = 0, + color_tolerance: Any = 5, + stop_action: str = "none", + description: Optional[str] = None, + tags: Optional[List[str]] = None, + # legacy compat + brightness_value_source_id: str = "", + ) -> Z2MLightOutputTarget: + """Create a Zigbee2MQTT light output target.""" + self._check_unique_name(name) + target_id, now = self._new_id_and_now() + target = Z2MLightOutputTarget( + id=target_id, + name=name, + target_type="z2m_light", + source_kind=source_kind if source_kind in _VALID_HA_SOURCE_KINDS else "css", + color_strip_source_id=color_strip_source_id, + color_value_source_id=color_value_source_id, + brightness=self._resolve_brightness(brightness, brightness_value_source_id), + light_mappings=z2m_light_mappings or [], + mqtt_source_id=mqtt_source_id, + base_topic=(base_topic or "").strip() or DEFAULT_Z2M_BASE_TOPIC, + update_rate=BindableFloat.from_raw(update_rate, default=5.0), + transition=self._resolve_transition(transition, default=0.3), + min_brightness_threshold=BindableFloat.from_raw(min_brightness_threshold, default=0.0), + color_tolerance=BindableFloat.from_raw(color_tolerance, default=5.0), + stop_action=stop_action if stop_action in _VALID_Z2M_STOP_ACTIONS else "none", + description=description, + created_at=now, + updated_at=now, + ) + return self._finalize(target, tags=tags, log_type="z2m_light") + + # ---- legacy generic shim (deferred deletion in Phase 5) ------------ + def create_target( self, name: str, @@ -53,101 +233,232 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): source_kind: str = "css", color_value_source_id: str = "", ha_light_mappings: Optional[List[HALightMapping]] = None, - update_rate: float = 2.0, + update_rate: Optional[float] = None, transition=None, color_tolerance: int = 5, stop_action: str = "none", - # legacy compat + z2m_light_mappings: Optional[List[Z2MLightMapping]] = None, + base_topic: str = DEFAULT_Z2M_BASE_TOPIC, + mqtt_source_id: str = "", brightness_value_source_id: str = "", ) -> OutputTarget: - """Create a new output target. + """Generic create that dispatches to the typed factory by ``target_type``. - Raises: - ValueError: If validation fails + Kept as a thin shim during the refactor — new code should call + :meth:`create_wled_target`, :meth:`create_ha_light_target`, or + :meth:`create_z2m_light_target` directly. """ - if target_type not in ("led", "ha_light"): - raise ValueError(f"Invalid target type: {target_type}") - - # Check for duplicate name - for target in self._items.values(): - if target.name == name: - raise ValueError(f"Output target with name '{name}' already exists") - - target_id = f"pt_{uuid.uuid4().hex[:8]}" - now = datetime.now(timezone.utc) - - # Resolve brightness to BindableFloat - if isinstance(brightness, BindableFloat): - bright = brightness - elif brightness is not None: - bright = BindableFloat.from_raw(brightness, default=1.0) - elif brightness_value_source_id: - bright = BindableFloat(1.0, source_id=brightness_value_source_id) - else: - bright = BindableFloat(1.0) - if target_type == "led": - target: OutputTarget = WledOutputTarget( - id=target_id, + return self.create_wled_target( name=name, - target_type="led", device_id=device_id, color_strip_source_id=color_strip_source_id, - brightness=bright, - fps=BindableFloat.from_raw(fps, default=30.0), + brightness=brightness, + fps=fps, keepalive_interval=keepalive_interval, state_check_interval=state_check_interval, - min_brightness_threshold=BindableFloat.from_raw( - min_brightness_threshold, default=0.0 - ), + min_brightness_threshold=min_brightness_threshold, adaptive_fps=adaptive_fps, protocol=protocol, description=description, - created_at=now, - updated_at=now, + tags=tags, + brightness_value_source_id=brightness_value_source_id, ) - elif target_type == "ha_light": - # Resolve transition - if isinstance(transition, BindableFloat): - trans = transition - elif transition is not None: - trans = BindableFloat.from_raw(transition, default=0.5) - else: - trans = BindableFloat(0.5) - - target = HALightOutputTarget( - id=target_id, + if target_type == "ha_light": + return self.create_ha_light_target( name=name, - target_type="ha_light", ha_source_id=ha_source_id, - source_kind=source_kind if source_kind in ("css", "color_vs") else "css", + source_kind=source_kind, color_strip_source_id=color_strip_source_id, color_value_source_id=color_value_source_id, - brightness=bright, - light_mappings=ha_light_mappings or [], - update_rate=BindableFloat.from_raw(update_rate, default=2.0), - transition=trans, - min_brightness_threshold=BindableFloat.from_raw( - min_brightness_threshold, default=0.0 - ), - color_tolerance=BindableFloat.from_raw(color_tolerance, default=5.0), - stop_action=( - stop_action if stop_action in ("none", "turn_off", "restore") else "none" - ), + brightness=brightness, + ha_light_mappings=ha_light_mappings, + update_rate=update_rate if update_rate is not None else 2.0, + transition=transition, + min_brightness_threshold=min_brightness_threshold, + color_tolerance=color_tolerance, + stop_action=stop_action, description=description, - created_at=now, - updated_at=now, + tags=tags, + brightness_value_source_id=brightness_value_source_id, ) - else: - raise ValueError(f"Unknown target type: {target_type}") + if target_type == "z2m_light": + return self.create_z2m_light_target( + name=name, + mqtt_source_id=mqtt_source_id, + source_kind=source_kind, + color_strip_source_id=color_strip_source_id, + color_value_source_id=color_value_source_id, + brightness=brightness, + z2m_light_mappings=z2m_light_mappings, + base_topic=base_topic, + update_rate=update_rate if update_rate is not None else 5.0, + transition=transition, + min_brightness_threshold=min_brightness_threshold, + color_tolerance=color_tolerance, + stop_action=stop_action, + description=description, + tags=tags, + brightness_value_source_id=brightness_value_source_id, + ) + raise ValueError(f"Invalid target type: {target_type}") - target.tags = tags or [] - self._items[target_id] = target - self._save_item(target_id, target) + # ---- typed update methods ------------------------------------------ - logger.info(f"Created output target: {name} ({target_id}, type={target_type})") + def _begin_update(self, target_id: str, new_name: Optional[str]) -> OutputTarget: + if target_id not in self._items: + raise ValueError(f"Output target not found: {target_id}") + target = self._items[target_id] + if new_name is not None: + for other in self._items.values(): + if other.id != target_id and other.name == new_name: + raise ValueError(f"Output target with name '{new_name}' already exists") return target + def _commit_update(self, target: OutputTarget) -> OutputTarget: + target.updated_at = datetime.now(timezone.utc) + self._save_item(target.id, target) + logger.info(f"Updated output target: {target.id}") + return target + + def update_wled_target( + self, + target_id: str, + *, + name: Optional[str] = None, + device_id: Optional[str] = None, + color_strip_source_id: Optional[str] = None, + brightness: Any = None, + fps: Any = None, + keepalive_interval: Optional[float] = None, + state_check_interval: Optional[int] = None, + min_brightness_threshold: Any = None, + adaptive_fps: Optional[bool] = None, + protocol: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + icon: Optional[str] = None, + icon_color: Optional[str] = None, + brightness_value_source_id: Optional[str] = None, + ) -> WledOutputTarget: + target = self._begin_update(target_id, name) + if not isinstance(target, WledOutputTarget): + raise ValueError(f"Target {target_id} is not a WLED target") + target.update_fields( + name=name, + device_id=device_id, + color_strip_source_id=color_strip_source_id, + brightness=brightness, + brightness_value_source_id=brightness_value_source_id, + fps=fps, + keepalive_interval=keepalive_interval, + state_check_interval=state_check_interval, + min_brightness_threshold=min_brightness_threshold, + adaptive_fps=adaptive_fps, + protocol=protocol, + description=description, + tags=tags, + icon=icon, + icon_color=icon_color, + ) + return self._commit_update(target) # type: ignore[return-value] + + def update_ha_light_target( + self, + target_id: str, + *, + name: Optional[str] = None, + ha_source_id: Optional[str] = None, + source_kind: Optional[str] = None, + color_strip_source_id: Optional[str] = None, + color_value_source_id: Optional[str] = None, + brightness: Any = None, + ha_light_mappings: Optional[List[HALightMapping]] = None, + update_rate: Any = None, + transition: Any = None, + min_brightness_threshold: Any = None, + color_tolerance: Any = None, + stop_action: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + icon: Optional[str] = None, + icon_color: Optional[str] = None, + brightness_value_source_id: Optional[str] = None, + ) -> HALightOutputTarget: + target = self._begin_update(target_id, name) + if not isinstance(target, HALightOutputTarget): + raise ValueError(f"Target {target_id} is not an HA-light target") + target.update_fields( + name=name, + ha_source_id=ha_source_id, + source_kind=source_kind, + color_strip_source_id=color_strip_source_id, + color_value_source_id=color_value_source_id, + brightness=brightness, + brightness_value_source_id=brightness_value_source_id, + light_mappings=ha_light_mappings, + update_rate=update_rate, + transition=transition, + min_brightness_threshold=min_brightness_threshold, + color_tolerance=color_tolerance, + stop_action=stop_action, + description=description, + tags=tags, + icon=icon, + icon_color=icon_color, + ) + return self._commit_update(target) # type: ignore[return-value] + + def update_z2m_light_target( + self, + target_id: str, + *, + name: Optional[str] = None, + mqtt_source_id: Optional[str] = None, + source_kind: Optional[str] = None, + color_strip_source_id: Optional[str] = None, + color_value_source_id: Optional[str] = None, + brightness: Any = None, + z2m_light_mappings: Optional[List[Z2MLightMapping]] = None, + base_topic: Optional[str] = None, + update_rate: Any = None, + transition: Any = None, + min_brightness_threshold: Any = None, + color_tolerance: Any = None, + stop_action: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + icon: Optional[str] = None, + icon_color: Optional[str] = None, + brightness_value_source_id: Optional[str] = None, + ) -> Z2MLightOutputTarget: + target = self._begin_update(target_id, name) + if not isinstance(target, Z2MLightOutputTarget): + raise ValueError(f"Target {target_id} is not a Z2M-light target") + target.update_fields( + name=name, + source_kind=source_kind, + color_strip_source_id=color_strip_source_id, + color_value_source_id=color_value_source_id, + brightness=brightness, + brightness_value_source_id=brightness_value_source_id, + light_mappings=z2m_light_mappings, + mqtt_source_id=mqtt_source_id, + base_topic=base_topic, + update_rate=update_rate, + transition=transition, + min_brightness_threshold=min_brightness_threshold, + color_tolerance=color_tolerance, + stop_action=stop_action, + description=description, + tags=tags, + icon=icon, + icon_color=icon_color, + ) + return self._commit_update(target) # type: ignore[return-value] + + # ---- legacy generic shim (deferred deletion in Phase 5) ------------ + def update_target( self, target_id: str, @@ -173,56 +484,83 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): transition=None, color_tolerance=None, stop_action=None, - # legacy compat + z2m_light_mappings=None, + base_topic=None, + mqtt_source_id=None, brightness_value_source_id=None, ) -> OutputTarget: - """Update an output target. + """Generic update that dispatches by the target's existing type. - Raises: - ValueError: If target not found or validation fails + Kept as a thin shim during the refactor — new code should call + :meth:`update_wled_target`, :meth:`update_ha_light_target`, or + :meth:`update_z2m_light_target` directly. """ if target_id not in self._items: raise ValueError(f"Output target not found: {target_id}") - target = self._items[target_id] - - if name is not None: - # Check for duplicate name (exclude self) - for other in self._items.values(): - if other.id != target_id and other.name == name: - raise ValueError(f"Output target with name '{name}' already exists") - - target.update_fields( - name=name, - device_id=device_id, - color_strip_source_id=color_strip_source_id, - brightness=brightness, - brightness_value_source_id=brightness_value_source_id, - fps=fps, - keepalive_interval=keepalive_interval, - state_check_interval=state_check_interval, - min_brightness_threshold=min_brightness_threshold, - adaptive_fps=adaptive_fps, - protocol=protocol, - description=description, - tags=tags, - icon=icon, - icon_color=icon_color, - ha_source_id=ha_source_id, - source_kind=source_kind, - color_value_source_id=color_value_source_id, - light_mappings=ha_light_mappings, - update_rate=update_rate, - transition=transition, - color_tolerance=color_tolerance, - stop_action=stop_action, - ) - - target.updated_at = datetime.now(timezone.utc) - self._save_item(target_id, target) - - logger.info(f"Updated output target: {target_id}") - return target + if isinstance(target, WledOutputTarget): + return self.update_wled_target( + target_id, + name=name, + device_id=device_id, + color_strip_source_id=color_strip_source_id, + brightness=brightness, + fps=fps, + keepalive_interval=keepalive_interval, + state_check_interval=state_check_interval, + min_brightness_threshold=min_brightness_threshold, + adaptive_fps=adaptive_fps, + protocol=protocol, + description=description, + tags=tags, + icon=icon, + icon_color=icon_color, + brightness_value_source_id=brightness_value_source_id, + ) + if isinstance(target, HALightOutputTarget): + return self.update_ha_light_target( + target_id, + name=name, + ha_source_id=ha_source_id, + source_kind=source_kind, + color_strip_source_id=color_strip_source_id, + color_value_source_id=color_value_source_id, + brightness=brightness, + ha_light_mappings=ha_light_mappings, + update_rate=update_rate, + transition=transition, + min_brightness_threshold=min_brightness_threshold, + color_tolerance=color_tolerance, + stop_action=stop_action, + description=description, + tags=tags, + icon=icon, + icon_color=icon_color, + brightness_value_source_id=brightness_value_source_id, + ) + if isinstance(target, Z2MLightOutputTarget): + return self.update_z2m_light_target( + target_id, + name=name, + mqtt_source_id=mqtt_source_id, + source_kind=source_kind, + color_strip_source_id=color_strip_source_id, + color_value_source_id=color_value_source_id, + brightness=brightness, + z2m_light_mappings=z2m_light_mappings, + base_topic=base_topic, + update_rate=update_rate, + transition=transition, + min_brightness_threshold=min_brightness_threshold, + color_tolerance=color_tolerance, + stop_action=stop_action, + description=description, + tags=tags, + icon=icon, + icon_color=icon_color, + brightness_value_source_id=brightness_value_source_id, + ) + raise ValueError(f"Unknown target class: {type(target).__name__}") def get_targets_for_device(self, device_id: str) -> List[OutputTarget]: """Get all targets that reference a specific device.""" @@ -240,6 +578,10 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): result.append(target.name) elif isinstance(target, HALightOutputTarget) and target.color_strip_source_id == css_id: result.append(target.name) + elif ( + isinstance(target, Z2MLightOutputTarget) and target.color_strip_source_id == css_id + ): + result.append(target.name) return result def count(self) -> int: diff --git a/server/src/ledgrab/storage/wled_output_target.py b/server/src/ledgrab/storage/wled_output_target.py index 727f078..824c451 100644 --- a/server/src/ledgrab/storage/wled_output_target.py +++ b/server/src/ledgrab/storage/wled_output_target.py @@ -12,7 +12,7 @@ DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds @dataclass -class WledOutputTarget(OutputTarget): +class WledOutputTarget(OutputTarget, type_key="led"): """LED output target — pairs an LED device with a ColorStripSource.""" device_id: str = "" diff --git a/server/src/ledgrab/storage/z2m_light_output_target.py b/server/src/ledgrab/storage/z2m_light_output_target.py new file mode 100644 index 0000000..78665fb --- /dev/null +++ b/server/src/ledgrab/storage/z2m_light_output_target.py @@ -0,0 +1,267 @@ +"""Zigbee2MQTT light output target — casts LED colors directly to Zigbee bulbs via MQTT. + +Bypasses Home Assistant: publishes RGB+brightness payloads to ``zigbee2mqtt//set`` +on the shared MQTT broker. Bulbs must already be paired with the Z2M coordinator and have +friendly names assigned in Z2M. +""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import List, Optional + +from ledgrab.storage.bindable import BindableFloat +from ledgrab.storage.output_target import OutputTarget + + +@dataclass +class Z2MLightMapping: + """Maps an LED range to a single Z2M bulb (by friendly name).""" + + friendly_name: str = "" # Z2M friendly_name, e.g. "living_room_bulb_1" + led_start: int = 0 # start LED index (0-based) + led_end: int = -1 # end LED index (-1 = last) + brightness_scale: BindableFloat = field(default_factory=lambda: BindableFloat(1.0)) + + def to_dict(self) -> dict: + return { + "friendly_name": self.friendly_name, + "led_start": self.led_start, + "led_end": self.led_end, + "brightness_scale": self.brightness_scale.to_dict(), + } + + @classmethod + def from_dict(cls, data: dict) -> "Z2MLightMapping": + return cls( + friendly_name=data.get("friendly_name", ""), + led_start=data.get("led_start", 0), + led_end=data.get("led_end", -1), + brightness_scale=BindableFloat.from_raw(data.get("brightness_scale"), default=1.0), + ) + + +VALID_STOP_ACTIONS = ("none", "turn_off") +VALID_SOURCE_KINDS = ("css", "color_vs") + +# Z2M base topic prefix. Standard installations use "zigbee2mqtt"; can be +# overridden per-target so users with a non-default Z2M ``mqtt.base_topic`` +# don't need code changes. +DEFAULT_Z2M_BASE_TOPIC = "zigbee2mqtt" + + +@dataclass +class Z2MLightOutputTarget(OutputTarget, type_key="z2m_light"): + """Output target that casts LED colors directly to Zigbee bulbs via MQTT (Z2M). + + Mirrors :class:`HALightOutputTarget` but skips Home Assistant entirely — + payloads are published to ``//set`` on the + shared MQTT broker configured under MQTT Sources. + + Two source modes are supported: + + * ``source_kind="css"`` — colours come from a ColorStripSource. Each + ``Z2MLightMapping`` averages a slice of the strip onto its bulb. + * ``source_kind="color_vs"`` — a single colour comes from a colour-returning + ``ValueSource``. The same colour is pushed to every mapped bulb; + ``led_start``/``led_end`` are ignored. + """ + + source_kind: str = "css" # one of VALID_SOURCE_KINDS + color_strip_source_id: str = "" + color_value_source_id: str = "" + brightness: BindableFloat = field(default_factory=lambda: BindableFloat(1.0)) + light_mappings: List[Z2MLightMapping] = field(default_factory=list) + # References an MQTTSource. Empty = unconfigured (target won't register/run + # until set). Multi-broker model: the runtime is acquired from MQTTManager + # using this id, so each target can talk to a different broker. + mqtt_source_id: str = "" + base_topic: str = DEFAULT_Z2M_BASE_TOPIC + # Z2M tolerates higher rates than HA — Zigbee mesh ceiling is the real + # bottleneck. Default 5 Hz, allow up to 10 Hz. + update_rate: BindableFloat = field(default_factory=lambda: BindableFloat(5.0)) + transition: BindableFloat = field(default_factory=lambda: BindableFloat(0.3)) + min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0)) + color_tolerance: BindableFloat = field(default_factory=lambda: BindableFloat(5.0)) + stop_action: str = "none" # one of VALID_STOP_ACTIONS + + def _has_source(self) -> bool: + if self.source_kind == "color_vs": + return bool(self.color_value_source_id) + return bool(self.color_strip_source_id) + + def register_with_manager(self, manager) -> None: + # Skip registration if no MQTT broker is selected — the processor + # cannot publish without a runtime to acquire. The target stays in + # the store so the user can fix the wiring via the editor. + if self.light_mappings and self._has_source() and self.mqtt_source_id: + manager.add_z2m_light_target( + target_id=self.id, + mqtt_source_id=self.mqtt_source_id, + source_kind=self.source_kind, + color_strip_source_id=self.color_strip_source_id, + color_value_source_id=self.color_value_source_id, + brightness=self.brightness, + light_mappings=self.light_mappings, + base_topic=self.base_topic, + update_rate=self.update_rate, + transition=self.transition, + min_brightness_threshold=self.min_brightness_threshold, + color_tolerance=self.color_tolerance, + stop_action=self.stop_action, + ) + + def sync_with_manager( + self, + manager, + *, + settings_changed: bool = False, + source_changed: bool = False, + device_changed: bool = False, + css_changed: bool = False, + **_kwargs, + ) -> None: + if settings_changed: + manager.update_target_settings( + self.id, + { + "source_kind": self.source_kind, + "color_value_source_id": self.color_value_source_id, + "brightness": self.brightness, + "mqtt_source_id": self.mqtt_source_id, + "base_topic": self.base_topic, + "update_rate": self.update_rate, + "transition": self.transition, + "min_brightness_threshold": self.min_brightness_threshold, + "color_tolerance": self.color_tolerance, + "light_mappings": self.light_mappings, + "stop_action": self.stop_action, + }, + ) + if css_changed: + manager.update_target_css(self.id, self.color_strip_source_id) + + def update_fields( + self, + *, + name=None, + source_kind=None, + color_strip_source_id=None, + color_value_source_id=None, + brightness=None, + # legacy compat + brightness_value_source_id=None, + light_mappings=None, + mqtt_source_id=None, + base_topic=None, + update_rate=None, + transition=None, + min_brightness_threshold=None, + color_tolerance=None, + stop_action=None, + description=None, + tags: Optional[List[str]] = None, + icon: Optional[str] = None, + icon_color: Optional[str] = None, + **_kwargs, + ) -> None: + super().update_fields( + name=name, + description=description, + tags=tags, + icon=icon, + icon_color=icon_color, + ) + if source_kind is not None and source_kind in VALID_SOURCE_KINDS: + self.source_kind = source_kind + if color_strip_source_id is not None: + self.color_strip_source_id = color_strip_source_id + if color_value_source_id is not None: + self.color_value_source_id = color_value_source_id + if mqtt_source_id is not None: + self.mqtt_source_id = mqtt_source_id + if brightness is not None: + self.brightness = self.brightness.apply_update(brightness) + elif brightness_value_source_id is not None: + self.brightness = BindableFloat( + value=self.brightness.value, + source_id=brightness_value_source_id, + ) + if light_mappings is not None: + self.light_mappings = light_mappings + if base_topic is not None: + self.base_topic = (base_topic or "").strip() or DEFAULT_Z2M_BASE_TOPIC + if update_rate is not None: + self.update_rate = self.update_rate.apply_update(update_rate) + if transition is not None: + self.transition = self.transition.apply_update(transition) + if min_brightness_threshold is not None: + self.min_brightness_threshold = self.min_brightness_threshold.apply_update( + min_brightness_threshold + ) + if color_tolerance is not None: + self.color_tolerance = self.color_tolerance.apply_update(color_tolerance) + if stop_action is not None and stop_action in VALID_STOP_ACTIONS: + self.stop_action = stop_action + + def to_dict(self) -> dict: + d = super().to_dict() + d["source_kind"] = self.source_kind + d["color_strip_source_id"] = self.color_strip_source_id + d["color_value_source_id"] = self.color_value_source_id + d["brightness"] = self.brightness.to_dict() + d["light_mappings"] = [m.to_dict() for m in self.light_mappings] + d["mqtt_source_id"] = self.mqtt_source_id + d["base_topic"] = self.base_topic + d["update_rate"] = self.update_rate.to_dict() + d["transition"] = self.transition.to_dict() + d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict() + d["color_tolerance"] = self.color_tolerance.to_dict() + d["stop_action"] = self.stop_action + return d + + @classmethod + def from_dict(cls, data: dict) -> "Z2MLightOutputTarget": + mappings = [Z2MLightMapping.from_dict(m) for m in data.get("light_mappings", [])] + brightness = BindableFloat.from_legacy( + data.get("brightness"), + legacy_source_id=data.get("brightness_value_source_id", ""), + default=1.0, + ) + raw_kind = data.get("source_kind", "css") + source_kind = raw_kind if raw_kind in VALID_SOURCE_KINDS else "css" + return cls( + id=data["id"], + name=data["name"], + target_type="z2m_light", + source_kind=source_kind, + color_strip_source_id=data.get("color_strip_source_id") or "", + color_value_source_id=data.get("color_value_source_id") or "", + brightness=brightness, + light_mappings=mappings, + # Legacy rows (pre-multi-broker) won't have this field — fall back + # to empty so the target is parked until the user picks a broker + # via the editor. We deliberately do NOT pick a "first" source as + # a silent default: that could publish to an unexpected broker. + mqtt_source_id=data.get("mqtt_source_id") or "", + base_topic=(data.get("base_topic") or DEFAULT_Z2M_BASE_TOPIC).strip() + or DEFAULT_Z2M_BASE_TOPIC, + update_rate=BindableFloat.from_raw(data.get("update_rate"), default=5.0), + transition=BindableFloat.from_raw(data.get("transition"), default=0.3), + min_brightness_threshold=BindableFloat.from_raw( + data.get("min_brightness_threshold"), default=0.0 + ), + color_tolerance=BindableFloat.from_raw(data.get("color_tolerance"), default=5.0), + stop_action=( + data["stop_action"] if data.get("stop_action") in VALID_STOP_ACTIONS else "none" + ), + description=data.get("description"), + tags=data.get("tags", []), + icon=data.get("icon", ""), + icon_color=data.get("icon_color", ""), + created_at=datetime.fromisoformat( + data.get("created_at", datetime.now(timezone.utc).isoformat()) + ), + updated_at=datetime.fromisoformat( + data.get("updated_at", datetime.now(timezone.utc).isoformat()) + ), + ) diff --git a/server/src/ledgrab/templates/index.html b/server/src/ledgrab/templates/index.html index 44fa3ef..cbe0065 100644 --- a/server/src/ledgrab/templates/index.html +++ b/server/src/ledgrab/templates/index.html @@ -256,6 +256,7 @@ {% include 'modals/ha-source-editor.html' %} {% include 'modals/mqtt-source-editor.html' %} {% include 'modals/ha-light-editor.html' %} + {% include 'modals/z2m-light-editor.html' %} {% include 'modals/asset-upload.html' %} {% include 'modals/asset-editor.html' %} {% include 'modals/game-integration-editor.html' %} diff --git a/server/src/ledgrab/templates/modals/css-editor.html b/server/src/ledgrab/templates/modals/css-editor.html index e63899e..841fa1c 100644 --- a/server/src/ledgrab/templates/modals/css-editor.html +++ b/server/src/ledgrab/templates/modals/css-editor.html @@ -225,7 +225,7 @@
- + diff --git a/server/src/ledgrab/templates/modals/ha-light-editor.html b/server/src/ledgrab/templates/modals/ha-light-editor.html index 5692982..da6e5c6 100644 --- a/server/src/ledgrab/templates/modals/ha-light-editor.html +++ b/server/src/ledgrab/templates/modals/ha-light-editor.html @@ -72,9 +72,7 @@
- + diff --git a/server/src/ledgrab/templates/modals/scene-preset-editor.html b/server/src/ledgrab/templates/modals/scene-preset-editor.html index 8d93026..941732e 100644 --- a/server/src/ledgrab/templates/modals/scene-preset-editor.html +++ b/server/src/ledgrab/templates/modals/scene-preset-editor.html @@ -52,8 +52,8 @@ -
- +
+ diff --git a/server/src/ledgrab/templates/modals/z2m-light-editor.html b/server/src/ledgrab/templates/modals/z2m-light-editor.html new file mode 100644 index 0000000..365f7a2 --- /dev/null +++ b/server/src/ledgrab/templates/modals/z2m-light-editor.html @@ -0,0 +1,182 @@ + + diff --git a/server/tests/core/test_ha_light_target_processor.py b/server/tests/core/test_ha_light_target_processor.py index 810df21..6c472f4 100644 --- a/server/tests/core/test_ha_light_target_processor.py +++ b/server/tests/core/test_ha_light_target_processor.py @@ -340,6 +340,89 @@ async def test_brightness_clamped_at_255(): assert turn_on.service_data["brightness"] == 255 +@pytest.mark.asyncio +async def test_turn_off_lights_when_idle_borrows_runtime_from_manager(): + """Manual turn-off must work even when the processor isn't running.""" + runtime = _FakeHARuntime() + ha_mgr = _FakeHAManager(runtime) + + proc = HALightTargetProcessor( + target_id="t_off_idle", + ha_source_id="ha_1", + source_kind="color_vs", + color_value_source_id="vs_x", + light_mappings=[_mapping("light.a"), _mapping("light.b"), _mapping("light.a")], + ctx=_make_ctx(ha_manager=ha_mgr), + ) + + count = await proc.turn_off_lights() + + assert count == 2 # duplicates collapsed + assert ha_mgr.acquired_for == ["ha_1"] + assert ha_mgr.released_for == ["ha_1"] + services = [(c.service, c.target["entity_id"]) for c in runtime.calls] + assert ("turn_off", "light.a") in services + assert ("turn_off", "light.b") in services + assert all(c.service == "turn_off" for c in runtime.calls) + + +@pytest.mark.asyncio +async def test_turn_off_lights_while_running_uses_existing_runtime(): + """If running, turn_off must reuse self._ha_runtime — not borrow another.""" + runtime = _FakeHARuntime() + ha_mgr = _FakeHAManager(runtime) + color_stream = _FakeColorStream((10, 20, 30)) + + proc = HALightTargetProcessor( + target_id="t_off_run", + ha_source_id="ha_1", + source_kind="color_vs", + color_value_source_id="vs_x", + light_mappings=[_mapping("light.a")], + color_tolerance=0, + ctx=_make_ctx(ha_manager=ha_mgr, vs_manager=_FakeVSManager(color_stream)), + ) + + await proc.start() + try: + # One acquire from start(); turn_off_lights must NOT add another. + before_acquires = list(ha_mgr.acquired_for) + before_releases = list(ha_mgr.released_for) + count = await proc.turn_off_lights() + finally: + await proc.stop() + + assert count == 1 + # No extra acquire/release pairs while running. + assert len(ha_mgr.acquired_for) == len(before_acquires) + assert len(ha_mgr.released_for) == len(before_releases) + 1 # +1 from stop() + assert any( + c.service == "turn_off" and c.target["entity_id"] == "light.a" for c in runtime.calls + ) + + +@pytest.mark.asyncio +async def test_turn_off_lights_no_mappings_returns_zero(): + runtime = _FakeHARuntime() + ha_mgr = _FakeHAManager(runtime) + + proc = HALightTargetProcessor( + target_id="t_off_empty", + ha_source_id="ha_1", + source_kind="color_vs", + color_value_source_id="vs_x", + light_mappings=[], + ctx=_make_ctx(ha_manager=ha_mgr), + ) + + count = await proc.turn_off_lights() + + assert count == 0 + # No need to acquire when there's nothing to do. + assert ha_mgr.acquired_for == [] + assert runtime.calls == [] + + @pytest.mark.asyncio async def test_get_state_reports_source_kind(): runtime = _FakeHARuntime() diff --git a/server/tests/storage/test_bindable.py b/server/tests/storage/test_bindable.py new file mode 100644 index 0000000..d8baa0d --- /dev/null +++ b/server/tests/storage/test_bindable.py @@ -0,0 +1,76 @@ +"""Tests for BindableFloat / BindableColor update semantics. + +The key behaviour pinned here: when ``apply_update`` receives a plain +primitive (number for float, ``[R,G,B]`` list for color) it must clear +``source_id`` -- that is how the client signals "unbind, use the static +value now". Previously the implementation preserved ``source_id`` on +plain-primitive updates, which silently dropped unbind requests. +""" + +from ledgrab.storage.bindable import BindableColor, BindableFloat + + +class TestBindableFloatApplyUpdate: + def test_plain_number_unbinds_when_previously_bound(self): + bf = BindableFloat(value=0.5, source_id="vs_abc") + updated = bf.apply_update(0.8) + assert updated.value == 0.8 + assert updated.source_id == "" + assert not updated.is_bound + + def test_plain_number_leaves_unbound_unbound(self): + bf = BindableFloat(value=0.5, source_id="") + updated = bf.apply_update(0.8) + assert updated.value == 0.8 + assert updated.source_id == "" + + def test_dict_with_source_id_binds(self): + bf = BindableFloat(value=0.5, source_id="") + updated = bf.apply_update({"value": 0.8, "source_id": "vs_new"}) + assert updated.value == 0.8 + assert updated.source_id == "vs_new" + assert updated.is_bound + + def test_dict_with_empty_source_id_unbinds(self): + bf = BindableFloat(value=0.5, source_id="vs_abc") + updated = bf.apply_update({"value": 0.8, "source_id": ""}) + assert updated.value == 0.8 + assert updated.source_id == "" + + def test_none_leaves_unchanged(self): + bf = BindableFloat(value=0.5, source_id="vs_abc") + updated = bf.apply_update(None) + assert updated is bf + + +class TestBindableColorApplyUpdate: + def test_plain_list_unbinds_when_previously_bound(self): + bc = BindableColor(color=[10, 20, 30], source_id="vs_abc") + updated = bc.apply_update([100, 150, 200]) + assert updated.color == [100, 150, 200] + assert updated.source_id == "" + assert not updated.is_bound + + def test_plain_list_leaves_unbound_unbound(self): + bc = BindableColor(color=[10, 20, 30], source_id="") + updated = bc.apply_update([100, 150, 200]) + assert updated.color == [100, 150, 200] + assert updated.source_id == "" + + def test_dict_with_source_id_binds(self): + bc = BindableColor(color=[10, 20, 30], source_id="") + updated = bc.apply_update({"color": [100, 150, 200], "source_id": "vs_new"}) + assert updated.color == [100, 150, 200] + assert updated.source_id == "vs_new" + assert updated.is_bound + + def test_dict_with_empty_source_id_unbinds(self): + bc = BindableColor(color=[10, 20, 30], source_id="vs_abc") + updated = bc.apply_update({"color": [100, 150, 200], "source_id": ""}) + assert updated.color == [100, 150, 200] + assert updated.source_id == "" + + def test_none_leaves_unchanged(self): + bc = BindableColor(color=[10, 20, 30], source_id="vs_abc") + updated = bc.apply_update(None) + assert updated is bc diff --git a/server/tests/test_config.py b/server/tests/test_config.py index 15b16e4..6e03950 100644 --- a/server/tests/test_config.py +++ b/server/tests/test_config.py @@ -37,10 +37,6 @@ class TestDefaultConfig: reload(paths_mod) assert paths_mod.default_data_dir() == Path(str(tmp_path / "custom")) - def test_default_mqtt_disabled(self): - config = Config() - assert config.mqtt.enabled is False - def test_default_demo_off(self): config = Config() assert config.demo is False