feat(mqtt): multi-broker MQTT + Zigbee2MQTT light target
- New Z2MLightOutputTarget storage, processor, editor and routes for Zigbee2MQTT light entities (shares the HA-Light editor UI via the new light-target-editor module) - Replace global MQTTService/MQTTConfig with per-source MQTTManager + MQTTRuntime; thread mqtt_source_id through Z2M targets, DIY MQTT devices, and the automation engine - Migrate legacy single-broker YAML/env config to a "Default Broker" MQTTSource on startup (core/mqtt/legacy_migration.py) and drop the obsolete core/mqtt/mqtt_service.py - Refresh /api/v1/system integration status to surface every MQTT source - Extract shared light-target editor and refactor OutputTargetStore + output_targets routes around typed factories / auto-registry - Modal CSS polish, locale strings, and storage/bindable test coverage
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -1,16 +1,48 @@
|
||||
"""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"
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
for m in ha_light_mappings_raw
|
||||
]
|
||||
if ha_light_mappings_raw
|
||||
else None
|
||||
)
|
||||
|
||||
# Create in store
|
||||
target = target_store.create_target(
|
||||
match data:
|
||||
case LedOutputTargetCreate():
|
||||
_validate_device_exists(device_store, data.device_id)
|
||||
target = target_store.create_wled_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"),
|
||||
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,
|
||||
)
|
||||
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),
|
||||
)
|
||||
for m in ha_light_mappings_raw
|
||||
]
|
||||
|
||||
# Update in store
|
||||
target = target_store.update_target(
|
||||
target_id=target_id,
|
||||
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,
|
||||
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),
|
||||
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,
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
# 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 and runtime.is_connected:
|
||||
if runtime is None or not runtime.is_connected:
|
||||
return False
|
||||
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)
|
||||
if value is None:
|
||||
return False
|
||||
matchers = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
return DeviceHealth(
|
||||
online=svc.is_connected,
|
||||
online=False,
|
||||
error="MQTT source not configured",
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
error=None if svc.is_connected else "MQTT broker disconnected",
|
||||
)
|
||||
runtime = mqtt_manager.get_runtime(mqtt_source_id)
|
||||
connected = bool(runtime and runtime.is_connected)
|
||||
return DeviceHealth(
|
||||
online=connected,
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
error=None if connected else "MQTT broker disconnected",
|
||||
)
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ``<base_topic>/<friendly_name>/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: <base_topic>/<friendly_name>/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"})
|
||||
+15
-22
@@ -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()
|
||||
|
||||
@@ -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) ──────────────────────── */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<WidgetKey, WidgetConfig> = {
|
||||
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<WidgetKey, string> = {
|
||||
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<WidgetConfig>` and merges over the defaults. */
|
||||
overrides?: Partial<Record<WidgetKey, Partial<WidgetConfig>>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Record<WidgetKey, BindableScalarWidget>> = {};
|
||||
|
||||
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:<id>` / `cvs:<id>` 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 `<select>`.
|
||||
*
|
||||
* Pass the existing `IconSelect | null` ref; the helper either calls
|
||||
* `updateItems()` on it or constructs a new one. The (new or existing)
|
||||
* instance is returned so the caller can keep the singleton ref alive.
|
||||
*/
|
||||
export function ensureStopActionIconSelect(
|
||||
selectId: string,
|
||||
items: IconSelectItem[],
|
||||
columns: number,
|
||||
existing: IconSelect | null,
|
||||
): IconSelect | null {
|
||||
const sel = document.getElementById(selectId) as HTMLSelectElement | null;
|
||||
if (!sel) return existing;
|
||||
if (existing) {
|
||||
existing.updateItems(items);
|
||||
return existing;
|
||||
}
|
||||
return new IconSelect({ target: sel, items, columns });
|
||||
}
|
||||
@@ -256,6 +256,37 @@ export let _cachedValueSources: ValueSource[] = [];
|
||||
export let _cachedSyncClocks: SyncClock[] = [];
|
||||
export let _cachedWeatherSources: WeatherSource[] = [];
|
||||
export let _cachedHASources: HomeAssistantSource[] = [];
|
||||
|
||||
/**
|
||||
* Friendly-name cache for HA entities, keyed by `ha_source_id`. Each
|
||||
* value is a flat `entity_id → friendly_name` map.
|
||||
*
|
||||
* Populated lazily — fetching entities hits the live HA instance, so
|
||||
* we cache once per session and refresh only when the user actively
|
||||
* picks a HA source in the value-source editor (see `_fetchVSHAEntities`)
|
||||
* or when `prefetchHAEntities` is explicitly invoked. Sources that are
|
||||
* offline at fetch time simply stay missing from the map; render code
|
||||
* must fall back to the raw `entity_id`.
|
||||
*/
|
||||
export let _haEntityNamesCache: Record<string, Record<string, string>> = {};
|
||||
|
||||
export function setHAEntityNames(
|
||||
haSourceId: string,
|
||||
entities: ReadonlyArray<{ entity_id?: string; friendly_name?: string }>,
|
||||
): void {
|
||||
if (!haSourceId) return;
|
||||
const map: Record<string, string> = {};
|
||||
for (const e of entities) {
|
||||
if (e.entity_id) map[e.entity_id] = e.friendly_name || e.entity_id;
|
||||
}
|
||||
_haEntityNamesCache = { ..._haEntityNamesCache, [haSourceId]: map };
|
||||
}
|
||||
|
||||
export function getHAEntityFriendlyName(haSourceId: string, entityId: string): string {
|
||||
if (!haSourceId || !entityId) return entityId || '';
|
||||
return _haEntityNamesCache[haSourceId]?.[entityId] || entityId;
|
||||
}
|
||||
|
||||
export let _cachedAssets: Asset[] = [];
|
||||
|
||||
// Automations
|
||||
|
||||
@@ -187,7 +187,7 @@ export function createAssetCard(asset: Asset): string {
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: 'READY',
|
||||
patchLabel: t('patch.ready'),
|
||||
iconActions,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -313,7 +313,7 @@ export function createAudioProcessingTemplateCard(tmpl: any): string {
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: 'PIPELINE',
|
||||
patchLabel: t('patch.pipeline'),
|
||||
iconActions: [
|
||||
{ icon: ICON_EDIT, onclick: `editAudioProcessingTemplate('${tmpl.id}')`, title: t('common.edit') },
|
||||
],
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
* Automations — automation cards, editor, rule builder, process picker, scene selector.
|
||||
*/
|
||||
|
||||
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache, _cachedHASources } from '../core/state.ts';
|
||||
import {
|
||||
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
|
||||
scenePresetsCache, _cachedHASources, haSourcesCache,
|
||||
getHAEntityFriendlyName, setHAEntityNames,
|
||||
} from '../core/state.ts';
|
||||
import { prefetchHAEntities } from './home-assistant-sources.ts';
|
||||
import { getHAEntityIcon } from '../core/icons.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
@@ -50,6 +55,9 @@ async function _loadHAEntitiesForRule(haSourceId: string, container: HTMLElement
|
||||
if (!resp.ok) { _haRuleEntities = []; return; }
|
||||
const data = await resp.json();
|
||||
_haRuleEntities = data.entities || [];
|
||||
// Mirror into the shared cache so automation/value-source card
|
||||
// chips pick up friendly names on the next render.
|
||||
setHAEntityNames(haSourceId, _haRuleEntities);
|
||||
} catch {
|
||||
_haRuleEntities = [];
|
||||
}
|
||||
@@ -217,12 +225,29 @@ export async function loadAutomations() {
|
||||
const [automations, scenes] = await Promise.all([
|
||||
automationsCacheObj.fetch(),
|
||||
scenePresetsCache.fetch(),
|
||||
haSourcesCache.fetch(),
|
||||
]);
|
||||
|
||||
const sceneMap = new Map(scenes.map(s => [s.id, s]));
|
||||
const activeCount = automations.filter(a => a.is_active).length;
|
||||
updateTabBadge('automations', activeCount);
|
||||
renderAutomations(automations, sceneMap);
|
||||
|
||||
// Background prefetch of HA entity friendly names referenced by
|
||||
// home_assistant rules. Re-render once names arrive.
|
||||
const haSourceIds: string[] = [];
|
||||
for (const a of automations) {
|
||||
for (const rule of ((a as any).rules || [])) {
|
||||
if (rule?.rule_type === 'home_assistant' && rule.ha_source_id) {
|
||||
haSourceIds.push(rule.ha_source_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (haSourceIds.length > 0) {
|
||||
prefetchHAEntities(haSourceIds).then(() => {
|
||||
if (csAutomations.isMounted()) renderAutomations(automations, sceneMap);
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to load automations:', error);
|
||||
@@ -306,11 +331,20 @@ const RULE_CHIP_RENDERERS: Record<string, RuleChipBuilder> = {
|
||||
title: t('automations.rule.mqtt'),
|
||||
}),
|
||||
webhook: () => ({ icon: ICON_WEB, text: t('automations.rule.webhook') }),
|
||||
home_assistant: (c) => ({
|
||||
home_assistant: (c) => {
|
||||
const entityId = c.entity_id || '';
|
||||
const friendly = getHAEntityFriendlyName(c.ha_source_id || '', entityId);
|
||||
// When the cache is missing, friendly === entityId; suppress the
|
||||
// tooltip in that case so we don't show a redundant duplicate.
|
||||
const tooltip = friendly !== entityId && entityId
|
||||
? `${entityId} = ${c.state || '*'}`
|
||||
: t('automations.rule.home_assistant');
|
||||
return {
|
||||
icon: _icon(P.home),
|
||||
text: `${c.entity_id || ''} = ${c.state || '*'}`,
|
||||
title: t('automations.rule.home_assistant'),
|
||||
}),
|
||||
text: `${friendly} = ${c.state || '*'}`,
|
||||
title: tooltip,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Render a chain-arrow separator span. `+` between AND-rules,
|
||||
@@ -823,6 +857,15 @@ function addAutomationRuleRow(rule: any) {
|
||||
</select>
|
||||
</div>
|
||||
</div>`;
|
||||
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') {
|
||||
|
||||
@@ -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<Record<string, CardMode>> = {
|
||||
'dashboard-running': 'compact',
|
||||
};
|
||||
|
||||
export interface CardModePrefsV1 {
|
||||
version: 1;
|
||||
surfaces: Record<string, CardMode>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 `<span class="stream-card-prop stream-card-link" title="${title}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${sourceId}')">${getValueSourceIcon(vs.source_type)} ${escapeHtml(vs.name)}</span>`;
|
||||
}
|
||||
return `<span class="stream-card-prop" title="${title}">${ICON_LINK_SOURCE} ${escapeHtml(sourceId)}</span>`;
|
||||
}
|
||||
const hex = rgbArrayToHex(bindableColor(color, fallback));
|
||||
return `<span class="stream-card-prop" title="${title}">
|
||||
<span style="display:inline-block;width:14px;height:14px;background:${hex};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hex.toUpperCase()}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
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 `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${css});flex-shrink:0"></span>`;
|
||||
@@ -73,11 +97,9 @@ const NON_PICTURE_TYPES = new Set([
|
||||
|
||||
const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
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 `
|
||||
<span class="stream-card-prop" title="${t('color_strip.static_color')}">
|
||||
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
|
||||
</span>
|
||||
${colorBadge}
|
||||
${animBadge}
|
||||
${clockBadge}
|
||||
`;
|
||||
@@ -145,13 +167,11 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
`;
|
||||
},
|
||||
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 `
|
||||
<span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}">
|
||||
<span style="display:inline-block;width:14px;height:14px;background:${fbColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${fbColor.toUpperCase()}
|
||||
</span>
|
||||
${fbBadge}
|
||||
<span class="stream-card-prop" title="${t('color_strip.api_input.timeout')}">${ICON_TIMER} ${timeoutVal}s</span>
|
||||
<span class="stream-card-prop" title="${t('color_strip.api_input.interpolation')}">${escapeHtml(interpLabel)}</span>
|
||||
`;
|
||||
@@ -162,12 +182,24 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
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 `<span class="stream-card-prop${linkCls}" title="${t('color_strip.notification.sound')}"${onclick}>${ICON_MUSIC} ${escapeHtml(name)}</span>`;
|
||||
})();
|
||||
return `
|
||||
<span class="stream-card-prop" title="${t('color_strip.notification.effect')}">${ICON_BELL} ${escapeHtml(effectLabel)}</span>
|
||||
<span class="stream-card-prop" title="${t('color_strip.notification.duration')}">${ICON_TIMER} ${durationVal}ms</span>
|
||||
<span class="stream-card-prop" title="${t('color_strip.notification.default_color')}">
|
||||
<span style="display:inline-block;width:14px;height:14px;background:${defColorHex};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${defColorHex.toUpperCase()}
|
||||
</span>
|
||||
${soundBadge}
|
||||
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
|
||||
`;
|
||||
},
|
||||
@@ -180,12 +212,10 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
`;
|
||||
},
|
||||
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 `
|
||||
<span class="stream-card-prop" title="${t('color_strip.candlelight.color')}">
|
||||
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
|
||||
</span>
|
||||
${colorBadge}
|
||||
<span class="stream-card-prop">${numCandles} ${t('color_strip.candlelight.num_candles')}</span>
|
||||
${clockBadge}
|
||||
`;
|
||||
@@ -361,7 +391,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: 'STRIP',
|
||||
patchLabel: t('patch.strip'),
|
||||
iconActions,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 `<div class="dashboard-target dashboard-autostart dashboard-card-link ${conn.connected ? 'is-running' : ''}" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','home_assistant','ha-sources','data-id','${conn.source_id}')}">
|
||||
@@ -386,7 +386,7 @@ function _renderMQTTIntegrationCard(conn: MQTTConnectionStatus): string {
|
||||
const subtitle = conn.connected ? escapeHtml(conn.broker) : t('mqtt_source.disconnected');
|
||||
const short = (conn.source_id || '').replace(/^src_/, '').slice(0, 2).toUpperCase() || 'MQ';
|
||||
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 `<div class="dashboard-target dashboard-autostart dashboard-card-link ${conn.connected ? 'is-running' : ''}" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','mqtt','mqtt-sources','data-id','${conn.source_id}')}">
|
||||
@@ -440,7 +440,7 @@ function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttS
|
||||
? (host ? `${host} · ${entitiesPart}` : entitiesPart)
|
||||
: (host ? `${host} · ${t('ha_source.disconnected')}` : t('ha_source.disconnected'));
|
||||
}
|
||||
applyState(card, conn.connected, conn.connected ? 'ONLINE' : 'OFFLINE');
|
||||
applyState(card, conn.connected, conn.connected ? t('patch.online') : t('patch.offline'));
|
||||
}
|
||||
if (mqttStatus) {
|
||||
for (const conn of mqttStatus.connections) {
|
||||
@@ -455,7 +455,7 @@ function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttS
|
||||
if (meta) {
|
||||
meta.textContent = conn.connected ? conn.broker : t('mqtt_source.disconnected');
|
||||
}
|
||||
applyState(card, conn.connected, conn.connected ? 'ONLINE' : 'OFFLINE');
|
||||
applyState(card, conn.connected, conn.connected ? t('patch.online') : t('patch.offline'));
|
||||
}
|
||||
}
|
||||
// Update section count badge
|
||||
@@ -479,7 +479,7 @@ function renderDashboardSyncClock(clock: SyncClock): string {
|
||||
].filter(Boolean);
|
||||
const short = (clock.id || '').replace(/^sc_/, '').slice(0, 2).toUpperCase() || 'CK';
|
||||
const ledCls = clock.is_running ? 'led on blink' : 'led';
|
||||
const patchLabel = clock.is_running ? 'TICKING' : 'PAUSED';
|
||||
const patchLabel = clock.is_running ? t('patch.ticking') : t('patch.paused');
|
||||
const patchLive = clock.is_running ? ' is-live' : '';
|
||||
const btnCls = clock.is_running ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
|
||||
const btnLabel = clock.is_running ? (t('sync_clock.action.pause') || 'Pause') : (t('sync_clock.action.resume') || 'Resume');
|
||||
@@ -1139,7 +1139,7 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
|
||||
</div>
|
||||
</div>
|
||||
<div class="mod-foot">
|
||||
<div class="mod-patch"><span class="patch-dot is-live"></span><span>PATCHED</span></div>
|
||||
<div class="mod-patch"><span class="patch-dot is-live"></span><span>${escapeHtml(t('patch.patched'))}</span></div>
|
||||
<button class="mod-btn mod-btn-stop" onclick="event.stopPropagation(); dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">${ICON_STOP_PLAIN} <span>${t('device.button.stop') || 'Stop'}</span></button>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -1158,7 +1158,7 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
|
||||
</div>
|
||||
</div>
|
||||
<div class="mod-foot">
|
||||
<div class="mod-patch"><span class="patch-dot"></span><span>STANDBY</span></div>
|
||||
<div class="mod-patch"><span class="patch-dot"></span><span>${escapeHtml(t('patch.standby'))}</span></div>
|
||||
<button class="mod-btn mod-btn-go" onclick="event.stopPropagation(); dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">${ICON_START} <span>${t('device.button.start') || 'Start'}</span></button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
|
||||
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
@@ -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<void> {
|
||||
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 {
|
||||
? `<option value="${escapeHtml(selectedId)}" selected>${escapeHtml(selectedId)}</option>`
|
||||
: '';
|
||||
|
||||
// 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 {
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const removeLabel = t('common.delete') || 'Remove';
|
||||
row.innerHTML = `
|
||||
<div class="ha-mapping-header">
|
||||
<span class="ha-mapping-label">${_icon(P.lightbulb)} #${idx}</span>
|
||||
<button type="button" class="btn-remove-mapping" onclick="removeHALightMapping(this)" title="${t('common.delete')}">${ICON_TRASH}</button>
|
||||
<div class="ha-mapping-icon" aria-hidden="true">${_icon(P.lightbulb)}</div>
|
||||
<span class="ha-mapping-name${previewIsEmpty ? ' is-empty' : ''}">${escapeHtml(previewText)}</span>
|
||||
<button type="button" class="ha-mapping-remove-btn" onclick="removeHALightMapping(this)" title="${escapeHtml(removeLabel)}" aria-label="${escapeHtml(removeLabel)}">${ICON_TRASH}</button>
|
||||
</div>
|
||||
<div class="ha-mapping-fields">
|
||||
<div class="ha-mapping-field">
|
||||
@@ -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 <select>'s value/selectedIndex and
|
||||
// dispatches `change` on it, so listening here is enough.
|
||||
const previewEl = row.querySelector('.ha-mapping-name') as HTMLElement | null;
|
||||
if (previewEl) {
|
||||
const placeholder = t('ha_light.mapping.unassigned') || '—';
|
||||
entitySelect.addEventListener('change', () => {
|
||||
const opt = entitySelect.options[entitySelect.selectedIndex];
|
||||
const hasValue = !!(opt && opt.value);
|
||||
previewEl.textContent = hasValue ? opt.text : placeholder;
|
||||
previewEl.classList.toggle('is-empty', !hasValue);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,59 +319,7 @@ export function removeHALightMapping(btn: HTMLElement): void {
|
||||
row.remove();
|
||||
}
|
||||
|
||||
// ── Bindable scalar widgets ──
|
||||
|
||||
function _ensureBrightnessWidget(): BindableScalarWidget {
|
||||
if (!_brightnessWidget) {
|
||||
_brightnessWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('ha-light-editor-brightness-container')!,
|
||||
min: 0, max: 1, step: 0.05, default: 1.0,
|
||||
idPrefix: 'ha-light-editor-brightness',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(2),
|
||||
});
|
||||
}
|
||||
return _brightnessWidget;
|
||||
}
|
||||
|
||||
function _ensureUpdateRateWidget(): BindableScalarWidget {
|
||||
if (!_updateRateWidget) {
|
||||
_updateRateWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('ha-light-editor-update-rate-container')!,
|
||||
min: 0.5, max: 5.0, step: 0.1, default: 2.0,
|
||||
idPrefix: 'ha-light-editor-update-rate',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(1),
|
||||
});
|
||||
}
|
||||
return _updateRateWidget;
|
||||
}
|
||||
|
||||
function _ensureTransitionWidget(): BindableScalarWidget {
|
||||
if (!_transitionWidget) {
|
||||
_transitionWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('ha-light-editor-transition-container')!,
|
||||
min: 0.0, max: 10.0, step: 0.1, default: 0.5,
|
||||
idPrefix: 'ha-light-editor-transition',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(1),
|
||||
});
|
||||
}
|
||||
return _transitionWidget;
|
||||
}
|
||||
|
||||
function _ensureColorToleranceWidget(): BindableScalarWidget {
|
||||
if (!_colorToleranceWidget) {
|
||||
_colorToleranceWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('ha-light-editor-color-tolerance-container')!,
|
||||
min: 0, max: 50, step: 1, default: 5,
|
||||
idPrefix: 'ha-light-editor-color-tolerance',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => String(Math.round(v)),
|
||||
});
|
||||
}
|
||||
return _colorToleranceWidget;
|
||||
}
|
||||
// ── Stop-action IconSelect items ──
|
||||
|
||||
function _stopActionItems() {
|
||||
return [
|
||||
@@ -374,24 +330,12 @@ function _stopActionItems() {
|
||||
}
|
||||
|
||||
function _ensureStopActionIconSelect(): void {
|
||||
const sel = document.getElementById('ha-light-editor-stop-action') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const items = _stopActionItems();
|
||||
if (_stopActionIconSelect) { _stopActionIconSelect.updateItems(items); return; }
|
||||
_stopActionIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
||||
}
|
||||
|
||||
function _ensureMinBrightnessThresholdWidget(): BindableScalarWidget {
|
||||
if (!_minBrightnessThresholdWidget) {
|
||||
_minBrightnessThresholdWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('ha-light-editor-min-brightness-threshold-container')!,
|
||||
min: 0, max: 254, step: 1, default: 0,
|
||||
idPrefix: 'ha-light-editor-min-brightness-threshold',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => String(Math.round(v)),
|
||||
});
|
||||
}
|
||||
return _minBrightnessThresholdWidget;
|
||||
_stopActionIconSelect = ensureStopActionIconSelect(
|
||||
'ha-light-editor-stop-action',
|
||||
_stopActionItems(),
|
||||
3,
|
||||
_stopActionIconSelect,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Show / Close ──
|
||||
@@ -456,14 +400,11 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
||||
_editorSourceKind = (editData.source_kind === 'color_vs') ? 'color_vs' : 'css';
|
||||
_editorColorVsId = editData.color_value_source_id || '';
|
||||
const editCssId = editData.color_strip_source_id || '';
|
||||
colorSelect.value = _editorSourceKind === 'color_vs'
|
||||
? (_editorColorVsId ? `cvs:${_editorColorVsId}` : '')
|
||||
: (editCssId ? `css:${editCssId}` : '');
|
||||
_ensureBrightnessWidget().setValue(editData.brightness ?? 1.0);
|
||||
_ensureUpdateRateWidget().setValue(editData.update_rate ?? 2.0);
|
||||
_ensureTransitionWidget().setValue(editData.transition ?? 0.5);
|
||||
_ensureColorToleranceWidget().setValue(editData.color_tolerance ?? 5);
|
||||
_ensureMinBrightnessThresholdWidget().setValue(editData.min_brightness_threshold ?? 0);
|
||||
colorSelect.value = encodeColorSourceValue(
|
||||
_editorSourceKind,
|
||||
_editorSourceKind === 'color_vs' ? _editorColorVsId : editCssId,
|
||||
);
|
||||
_widgets.applyValues(editData);
|
||||
(document.getElementById('ha-light-editor-stop-action') as HTMLSelectElement).value = editData.stop_action ?? 'none';
|
||||
_ensureStopActionIconSelect();
|
||||
if (_stopActionIconSelect) _stopActionIconSelect.setValue(editData.stop_action ?? 'none', false);
|
||||
@@ -479,11 +420,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
||||
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = '';
|
||||
_editorSourceKind = 'css';
|
||||
_editorColorVsId = '';
|
||||
_ensureBrightnessWidget().setValue(1.0);
|
||||
_ensureUpdateRateWidget().setValue(2.0);
|
||||
_ensureTransitionWidget().setValue(0.5);
|
||||
_ensureColorToleranceWidget().setValue(5);
|
||||
_ensureMinBrightnessThresholdWidget().setValue(0);
|
||||
_widgets.applyDefaults();
|
||||
(document.getElementById('ha-light-editor-stop-action') as HTMLSelectElement).value = 'none';
|
||||
_ensureStopActionIconSelect();
|
||||
if (_stopActionIconSelect) _stopActionIconSelect.setValue('none', false);
|
||||
@@ -522,13 +459,8 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
||||
onChange: (rawValue: string) => {
|
||||
// Decode "css:<id>" or "cvs:<id>" into source kind + id, then re-render
|
||||
// mapping rows so the LED-range fields appear/disappear in step.
|
||||
const newKind: HALightSourceKind = rawValue.startsWith('cvs:') ? 'color_vs' : 'css';
|
||||
const id = rawValue.includes(':') ? rawValue.slice(rawValue.indexOf(':') + 1) : '';
|
||||
if (newKind === 'color_vs') {
|
||||
_editorColorVsId = id;
|
||||
} else {
|
||||
_editorColorVsId = '';
|
||||
}
|
||||
const { kind: newKind, id } = decodeColorSourceValue(rawValue);
|
||||
_editorColorVsId = newKind === 'color_vs' ? id : '';
|
||||
if (newKind !== _editorSourceKind) {
|
||||
_editorSourceKind = newKind;
|
||||
_rerenderMappingsForMode();
|
||||
@@ -559,16 +491,14 @@ export async function saveHALightEditor(): Promise<void> {
|
||||
const haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value;
|
||||
const colorSourceRaw = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value;
|
||||
// Decode the unified picker value: "css:<id>" or "cvs:<id>" (or "" for none).
|
||||
const sourceKind: HALightSourceKind = colorSourceRaw.startsWith('cvs:') ? 'color_vs' : 'css';
|
||||
const colorSourceId = colorSourceRaw.includes(':')
|
||||
? colorSourceRaw.slice(colorSourceRaw.indexOf(':') + 1)
|
||||
: '';
|
||||
const { kind: sourceKind, id: colorSourceId } = decodeColorSourceValue(colorSourceRaw);
|
||||
const cssSourceId = sourceKind === 'css' ? colorSourceId : '';
|
||||
const colorValueSourceId = sourceKind === 'color_vs' ? colorSourceId : '';
|
||||
const updateRate = _updateRateWidget ? _updateRateWidget.getValue() : 2.0;
|
||||
const transition = _transitionWidget ? _transitionWidget.getValue() : 0.5;
|
||||
const colorTolerance = _colorToleranceWidget ? _colorToleranceWidget.getValue() : 5;
|
||||
const minBrightnessThreshold = _minBrightnessThresholdWidget ? _minBrightnessThresholdWidget.getValue() : 0;
|
||||
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('ha-light-editor-stop-action') as HTMLSelectElement).value;
|
||||
const stopAction: 'none' | 'turn_off' | 'restore' =
|
||||
stopActionRaw === 'turn_off' || stopActionRaw === 'restore' ? stopActionRaw : 'none';
|
||||
@@ -590,8 +520,6 @@ export async function saveHALightEditor(): Promise<void> {
|
||||
// Collect mappings
|
||||
const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.entity_id);
|
||||
|
||||
const brightness = _brightnessWidget ? _brightnessWidget.getValue() : 1.0;
|
||||
|
||||
const payload: any = {
|
||||
name,
|
||||
ha_source_id: haSourceId,
|
||||
@@ -678,10 +606,11 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
|
||||
// ── Badge / meta ──
|
||||
const badgeText = 'HA · LIGHT';
|
||||
const haLabel = haSource ? haSource.name : (target.ha_source_id || '—');
|
||||
const haConnectionTitle = t('ha_light.connection_tooltip');
|
||||
const haLink = haSource
|
||||
? `<a class="mod-meta__link" role="button" tabindex="0" onclick="event.stopPropagation(); navigateToCard('integrations','home_assistant','ha-sources','data-id','${target.ha_source_id}')" title="HA Connection">${escapeHtml(haLabel)}</a>`
|
||||
? `<a class="mod-meta__link" role="button" tabindex="0" onclick="event.stopPropagation(); navigateToCard('integrations','home_assistant','ha-sources','data-id','${target.ha_source_id}')" title="${escapeHtml(haConnectionTitle)}">${escapeHtml(haLabel)}</a>`
|
||||
: escapeHtml(haLabel);
|
||||
const lightsLabel = `${mappingCount} ${mappingCount === 1 ? 'light' : 'lights'}`;
|
||||
const lightsLabel = t('ha_light.lights', { count: mappingCount });
|
||||
const rateLabel = `${target.update_rate ?? 2.0} Hz`;
|
||||
const metaHtml = [haLink, lightsLabel, rateLabel].join(' · ');
|
||||
|
||||
@@ -749,7 +678,7 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
|
||||
k: 'HA',
|
||||
icon: state.ha_connected ? ICON_OK : ICON_WARNING,
|
||||
v: '<span data-tm="ha-status">---</span>',
|
||||
title: 'Home Assistant connection',
|
||||
title: t('ha_light.connection_metric'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -763,38 +692,50 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
|
||||
isRunning ? (state.ha_connected === false ? 'offline' : 'live') :
|
||||
haSource ? 'standby' : 'idle';
|
||||
const patchLabel = isRunning
|
||||
? (state.ha_connected === false ? 'DISCONNECTED' : 'STREAMING')
|
||||
? (state.ha_connected === false ? t('patch.disconnected') : t('patch.streaming'))
|
||||
: haSource
|
||||
? 'STANDBY'
|
||||
: 'NOT CONFIGURED';
|
||||
? t('patch.standby')
|
||||
: t('patch.not_configured');
|
||||
|
||||
// ── Foot actions — uses event delegation via [data-action] ──
|
||||
const primaryAction: ModBtnOpts = isRunning
|
||||
? {
|
||||
label: 'STOP',
|
||||
label: t('common.stop'),
|
||||
icon: ICON_STOP,
|
||||
onclick: 'void 0',
|
||||
title: t('targets.stop'),
|
||||
title: t('device.button.stop'),
|
||||
variant: 'stop',
|
||||
dataAttrs: { 'data-action': 'stop' },
|
||||
}
|
||||
: {
|
||||
label: 'START',
|
||||
label: t('common.start'),
|
||||
icon: ICON_START,
|
||||
onclick: 'void 0',
|
||||
title: t('targets.start'),
|
||||
title: t('device.button.start'),
|
||||
variant: 'go',
|
||||
dataAttrs: { 'data-action': 'start' },
|
||||
};
|
||||
|
||||
const iconActions: ModBtnOpts[] = [
|
||||
{
|
||||
const iconActions: ModBtnOpts[] = [];
|
||||
|
||||
// Power-off shortcut: only useful when at least one entity is mapped.
|
||||
// Mirrors the LED-device card's stop-style power button.
|
||||
if (mappingCount > 0) {
|
||||
iconActions.push({
|
||||
icon: ICON_STOP_PLAIN,
|
||||
onclick: 'void 0',
|
||||
title: t('ha_light.button.turn_off') || 'Turn Off Lights',
|
||||
variant: 'stop',
|
||||
dataAttrs: { 'data-action': 'turn-off' },
|
||||
});
|
||||
}
|
||||
|
||||
iconActions.push({
|
||||
icon: ICON_EDIT,
|
||||
onclick: 'void 0',
|
||||
title: t('common.edit'),
|
||||
dataAttrs: { 'data-action': 'edit' },
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// ── Custom icon plate (HA-light targets have no inheritance source) ──
|
||||
const targetIconId = (target.icon as string | undefined) || '';
|
||||
@@ -876,7 +817,7 @@ export function patchHALightTargetMetrics(target: any): void {
|
||||
if (haEl) haEl.innerHTML = state.ha_connected ? ICON_OK : ICON_WARNING;
|
||||
|
||||
const swatchesEl = card.querySelector(`[data-ha-swatches="${target.id}"]`) as HTMLElement | null;
|
||||
if (swatchesEl) swatchesEl.innerHTML = _renderEntitySwatches(state.entity_colors || {}, target.ha_light_mappings || []);
|
||||
if (swatchesEl) swatchesEl.innerHTML = _renderEntitySwatches(state.entity_colors || {}, target.ha_light_mappings || [], target.ha_source_id || '');
|
||||
}
|
||||
|
||||
// ── Event delegation ──
|
||||
@@ -886,6 +827,7 @@ const _haLightActions: Record<string, (id: string) => void> = {
|
||||
stop: (id) => _startStop(id, 'stop'),
|
||||
clone: cloneHALightTarget,
|
||||
edit: editHALightTarget,
|
||||
'turn-off': (id) => { void turnOffHALightTarget(id); },
|
||||
};
|
||||
|
||||
async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> {
|
||||
@@ -900,6 +842,26 @@ async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
export async function turnOffHALightTarget(targetId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('confirm.turn_off_ha_light') || 'Turn off mapped lights?');
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(
|
||||
`/output-targets/${targetId}/ha-light/turn-off`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (resp.ok) {
|
||||
showToast(t('ha_light.turn_off.success') || 'Lights turned off', 'success');
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showToast(err.detail || t('ha_light.turn_off.failed') || 'Failed to turn off lights', 'error');
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(t('ha_light.turn_off.failed') || 'Failed to turn off lights', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function initHALightTargetDelegation(container: HTMLElement): void {
|
||||
container.addEventListener('click', (e: MouseEvent) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
|
||||
@@ -924,13 +886,20 @@ export function initHALightTargetDelegation(container: HTMLElement): void {
|
||||
|
||||
// ── Entity color swatches ──
|
||||
|
||||
function _renderEntitySwatches(entityColors: Record<string, any>, mappings: any[]): string {
|
||||
function _renderEntitySwatches(
|
||||
entityColors: Record<string, any>,
|
||||
mappings: any[],
|
||||
haSourceId: string = '',
|
||||
): string {
|
||||
if (!mappings.length) return '';
|
||||
return mappings.map(m => {
|
||||
const c = entityColors[m.entity_id];
|
||||
const bg = c ? c.hex : '#333';
|
||||
const label = m.entity_id.replace('light.', '');
|
||||
return `<div class="ha-light-swatch" data-entity="${escapeHtml(m.entity_id)}">
|
||||
// Prefer the cached friendly name; fall back to the raw entity_id
|
||||
// (minus the `light.` domain prefix) when no name is cached yet.
|
||||
const friendly = getHAEntityFriendlyName(haSourceId, m.entity_id);
|
||||
const label = friendly !== m.entity_id ? friendly : m.entity_id.replace('light.', '');
|
||||
return `<div class="ha-light-swatch" data-entity="${escapeHtml(m.entity_id)}" title="${escapeHtml(m.entity_id)}">
|
||||
<span class="swatch-color" style="background:${bg}"></span>
|
||||
<span class="swatch-label">${escapeHtml(label)}</span>
|
||||
</div>`;
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
* Home Assistant Sources — CRUD, test, cards.
|
||||
*/
|
||||
|
||||
import { _cachedHASources, haSourcesCache } from '../core/state.ts';
|
||||
import {
|
||||
_cachedHASources, haSourcesCache,
|
||||
_haEntityNamesCache, setHAEntityNames,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
@@ -59,6 +62,36 @@ class HASourceModal extends Modal {
|
||||
|
||||
const haSourceModal = new HASourceModal();
|
||||
|
||||
// ── Entity friendly-name cache ──────────────────────────────────
|
||||
//
|
||||
// Fetching entities calls the live HA instance, so we only refresh
|
||||
// the cache when the user actively interacts with a source. The
|
||||
// Streams tab triggers `prefetchHAEntities()` for sources referenced
|
||||
// by ha_entity value sources on initial load; subsequent edits in
|
||||
// the value-source editor refresh via `_fetchVSHAEntities`.
|
||||
|
||||
/** Fetch entities for one HA source and write friendly names into the
|
||||
* shared cache. Failures are silent — callers fall back to entity_id. */
|
||||
export async function fetchHAEntities(haSourceId: string): Promise<void> {
|
||||
if (!haSourceId) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
setHAEntityNames(haSourceId, data.entities || []);
|
||||
} catch {
|
||||
// Leave any existing cache entry intact.
|
||||
}
|
||||
}
|
||||
|
||||
/** Prefetch entities for the given HA source IDs in parallel,
|
||||
* skipping any already cached this session. */
|
||||
export async function prefetchHAEntities(haSourceIds: ReadonlyArray<string>): Promise<void> {
|
||||
const unique = [...new Set(haSourceIds.filter(id => id && !_haEntityNamesCache[id]))];
|
||||
if (unique.length === 0) return;
|
||||
await Promise.all(unique.map(id => fetchHAEntities(id)));
|
||||
}
|
||||
|
||||
// ── Show / Close ──
|
||||
|
||||
export async function showHASourceModal(editData: HomeAssistantSource | null = null): Promise<void> {
|
||||
|
||||
@@ -641,7 +641,7 @@ function _paintNetworkValue(bytesPerSec: number, totalBytes: number): void {
|
||||
}
|
||||
const subEl = document.getElementById('perf-network-sub');
|
||||
if (subEl) {
|
||||
subEl.textContent = totalBytes > 0 ? `${_formatBytes(totalBytes)} total` : '';
|
||||
subEl.textContent = totalBytes > 0 ? t('perf.total_bytes', { bytes: _formatBytes(totalBytes) }) : '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -654,9 +654,9 @@ function _paintDeviceLatencyValue(
|
||||
const valEl = document.getElementById('perf-device_latency-value');
|
||||
if (valEl) {
|
||||
if (totalCount === 0) {
|
||||
valEl.innerHTML = '<span class="perf-chart-hint">no devices</span>';
|
||||
valEl.innerHTML = `<span class="perf-chart-hint">${t('perf.no_devices')}</span>`;
|
||||
} else if (avgMs == null) {
|
||||
valEl.innerHTML = '<span class="perf-chart-hint">offline</span>';
|
||||
valEl.innerHTML = `<span class="perf-chart-hint">${t('perf.offline')}</span>`;
|
||||
} else {
|
||||
const txt = avgMs < 10 ? avgMs.toFixed(1) : avgMs.toFixed(0);
|
||||
valEl.innerHTML = `${txt}<span class="perf-fps-unit">ms</span>`;
|
||||
@@ -665,8 +665,8 @@ function _paintDeviceLatencyValue(
|
||||
const subEl = document.getElementById('perf-device_latency-sub');
|
||||
if (subEl) {
|
||||
const parts: string[] = [];
|
||||
if (totalCount > 0) parts.push(`${onlineCount}/${totalCount} online`);
|
||||
if (maxMs != null) parts.push(`max ${maxMs < 10 ? maxMs.toFixed(1) : maxMs.toFixed(0)}ms`);
|
||||
if (totalCount > 0) parts.push(`${onlineCount}/${totalCount} ${t('perf.online')}`);
|
||||
if (maxMs != null) parts.push(t('perf.max_ms', { ms: maxMs < 10 ? maxMs.toFixed(1) : maxMs.toFixed(0) }));
|
||||
subEl.textContent = parts.join(' · ');
|
||||
}
|
||||
}
|
||||
@@ -675,7 +675,7 @@ function _paintSendTimingValue(avgMs: number, maxMs: number, reportingCount: num
|
||||
const valEl = document.getElementById('perf-send_timing-value');
|
||||
if (valEl) {
|
||||
if (reportingCount === 0) {
|
||||
valEl.innerHTML = '<span class="perf-chart-hint">idle</span>';
|
||||
valEl.innerHTML = `<span class="perf-chart-hint">${t('perf.idle')}</span>`;
|
||||
} else {
|
||||
const txt = avgMs < 10 ? avgMs.toFixed(1) : avgMs.toFixed(0);
|
||||
valEl.innerHTML = `${txt}<span class="perf-fps-unit">ms</span>`;
|
||||
@@ -687,7 +687,8 @@ function _paintSendTimingValue(avgMs: number, maxMs: number, reportingCount: num
|
||||
subEl.textContent = '';
|
||||
} else {
|
||||
const maxTxt = maxMs < 10 ? maxMs.toFixed(1) : maxMs.toFixed(0);
|
||||
subEl.textContent = `max ${maxTxt}ms · ${reportingCount} target${reportingCount > 1 ? 's' : ''}`;
|
||||
const targetsLabel = t('perf.targets_count', { count: reportingCount });
|
||||
subEl.textContent = `${t('perf.max_ms', { ms: maxTxt })} · ${targetsLabel}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -766,8 +767,8 @@ export function updateDevices(
|
||||
const subEl = document.getElementById('perf-devices-sub');
|
||||
if (subEl) {
|
||||
subEl.textContent = offline === 0
|
||||
? 'all online'
|
||||
: `${offline} offline`;
|
||||
? t('perf.all_online')
|
||||
: t('perf.offline_count', { count: offline });
|
||||
}
|
||||
|
||||
const dotsEl = document.getElementById('perf-devices-dots');
|
||||
@@ -783,7 +784,7 @@ export function updateDevices(
|
||||
const dots = shown.map(s => {
|
||||
const name = s.device_name || s.device_id;
|
||||
const latency = s.device_latency_ms != null ? ` · ${s.device_latency_ms.toFixed(0)}ms` : '';
|
||||
const title = `${name}${s.device_online ? ' · online' : ' · offline'}${latency}`;
|
||||
const title = `${name} · ${s.device_online ? t('perf.online') : t('perf.offline')}${latency}`;
|
||||
return `<span class="perf-devices-dot ${s.device_online ? 'is-online' : 'is-offline'}" title="${escapeText(title)}"></span>`;
|
||||
}).join('');
|
||||
const more = overflow > 0
|
||||
|
||||
@@ -38,9 +38,63 @@ registerIconEntityType('scene_preset', makeSimpleIconAdapter<ScenePreset>({
|
||||
}));
|
||||
|
||||
let _editingId: string | null = null;
|
||||
let _allTargets: any[] = []; // fetched on capture open
|
||||
let _allTargets: any[] = []; // fetched on capture/edit open
|
||||
let _deviceMap: Record<string, any> = {}; // primed alongside _allTargets so target rows can resolve device-derived icons synchronously
|
||||
let _sceneTagsInput: TagInput | null = null;
|
||||
|
||||
// ── Target row rendering ──
|
||||
//
|
||||
// Three call sites (capture-open / edit-open / clone-open + the user's
|
||||
// "+ Add Target" pick) all need the same patch-bay channel markup, so
|
||||
// render it once here. Edit/clone used to emit a stripped row without
|
||||
// the icon — funneling everything through this helper fixes that.
|
||||
|
||||
function _targetTypeLabel(tgt: any): string {
|
||||
return tgt?.target_type === 'ha_light' ? 'HA · LIGHT' : 'LED';
|
||||
}
|
||||
|
||||
function _resolveTargetIconSvg(tgt: any): string {
|
||||
let svg = renderDeviceIconSvg(tgt.icon, { size: 18 });
|
||||
if (!svg && tgt.target_type === 'led' && tgt.device_id) {
|
||||
svg = renderDeviceIconSvg(_deviceMap[tgt.device_id]?.icon, { size: 18 });
|
||||
}
|
||||
return svg || ICON_TARGET;
|
||||
}
|
||||
|
||||
function _renderSceneTargetRowHtml(tgt: any): string {
|
||||
const removeLabel = t('common.remove') || 'Remove';
|
||||
return `
|
||||
<div class="scene-target-icon" aria-hidden="true">${_resolveTargetIconSvg(tgt)}</div>
|
||||
<div class="scene-target-id">
|
||||
<span class="scene-target-name">${escapeHtml(tgt.name)}</span>
|
||||
<span class="scene-target-type">${_targetTypeLabel(tgt)}</span>
|
||||
</div>
|
||||
<button type="button" class="scene-target-remove" data-action="remove-scene-target" title="${escapeHtml(removeLabel)}" aria-label="${escapeHtml(removeLabel)}">${ICON_TRASH}</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function _appendSceneTargetItem(tgt: any, listEl: HTMLElement): void {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'scene-target-item';
|
||||
item.dataset.targetId = tgt.id;
|
||||
item.innerHTML = _renderSceneTargetRowHtml(tgt);
|
||||
listEl.appendChild(item);
|
||||
}
|
||||
|
||||
async function _primeTargetSelector(): Promise<void> {
|
||||
const [targets, devices] = await Promise.all([
|
||||
outputTargetsCache.fetch().catch((): any[] => []),
|
||||
devicesCache.fetch().catch((): any[] => []),
|
||||
]);
|
||||
_allTargets = targets;
|
||||
_deviceMap = {};
|
||||
for (const d of devices) _deviceMap[d.id] = d;
|
||||
}
|
||||
|
||||
function _setSceneTargetListEmptyHint(listEl: HTMLElement): void {
|
||||
listEl.dataset.empty = t('scenes.targets.empty') || 'No targets selected';
|
||||
}
|
||||
|
||||
// ── Auto-name ──
|
||||
|
||||
let _spNameManuallyEdited = false;
|
||||
@@ -224,7 +278,7 @@ function _renderDashboardPresetCard(preset: ScenePreset): string {
|
||||
</div>
|
||||
</div>
|
||||
<div class="mod-foot">
|
||||
<div class="mod-patch"><span class="patch-dot"></span><span>PRESET</span></div>
|
||||
<div class="mod-patch"><span class="patch-dot"></span><span>${escapeHtml(t('patch.preset'))}</span></div>
|
||||
<button class="mod-btn mod-btn-go" data-action="activate-scene" data-id="${preset.id}" title="${activateLabel}" onclick="event.stopPropagation();">${ICON_START} <span>${activateLabel}</span></button>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -249,8 +303,9 @@ export async function openScenePresetCapture(): Promise<void> {
|
||||
if (selectorGroup && targetList) {
|
||||
selectorGroup.style.display = '';
|
||||
targetList.innerHTML = '';
|
||||
_setSceneTargetListEmptyHint(targetList);
|
||||
try {
|
||||
_allTargets = await outputTargetsCache.fetch().catch((): any[] => []);
|
||||
await _primeTargetSelector();
|
||||
_refreshTargetSelect();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -289,19 +344,15 @@ export async function editScenePreset(presetId: string): Promise<void> {
|
||||
if (selectorGroup && targetList) {
|
||||
selectorGroup.style.display = '';
|
||||
targetList.innerHTML = '';
|
||||
_setSceneTargetListEmptyHint(targetList);
|
||||
try {
|
||||
_allTargets = await outputTargetsCache.fetch().catch((): any[] => []);
|
||||
await _primeTargetSelector();
|
||||
|
||||
// Pre-add targets already in the preset
|
||||
const presetTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
|
||||
for (const tid of presetTargetIds) {
|
||||
const tgt = _allTargets.find(t => t.id === tid);
|
||||
if (!tgt) continue;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'scene-target-item';
|
||||
item.dataset.targetId = tid;
|
||||
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">${ICON_TRASH}</button>`;
|
||||
targetList.appendChild(item);
|
||||
_appendSceneTargetItem(tgt, targetList);
|
||||
}
|
||||
_refreshTargetSelect();
|
||||
} catch { /* ignore */ }
|
||||
@@ -389,23 +440,20 @@ function _refreshTargetSelect(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function _addTargetToList(targetId: string, targetName: string, iconHtml?: string): void {
|
||||
function _addTargetToList(tgt: any): void {
|
||||
const list = document.getElementById('scene-target-list');
|
||||
if (!list) return;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'scene-target-item';
|
||||
item.dataset.targetId = targetId;
|
||||
item.innerHTML = `<span>${iconHtml || ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">${ICON_TRASH}</button>`;
|
||||
list.appendChild(item);
|
||||
_appendSceneTargetItem(tgt, list);
|
||||
_refreshTargetSelect();
|
||||
}
|
||||
|
||||
function _resolveTargetIcon(tgt: any, deviceMap: Record<string, any>): string {
|
||||
// Picker icon — keeps the `<svg class="icon">` shape EntityPalette expects.
|
||||
function _resolveTargetPickerIcon(tgt: any): string {
|
||||
let icon = renderDeviceIcon(tgt.icon);
|
||||
if (!icon && tgt.target_type === 'led' && tgt.device_id) {
|
||||
icon = renderDeviceIcon(deviceMap[tgt.device_id]?.icon);
|
||||
icon = renderDeviceIcon(_deviceMap[tgt.device_id]?.icon);
|
||||
}
|
||||
return icon;
|
||||
return icon || ICON_TARGET;
|
||||
}
|
||||
|
||||
export async function addSceneTarget(): Promise<void> {
|
||||
@@ -413,14 +461,10 @@ export async function addSceneTarget(): Promise<void> {
|
||||
const available = _allTargets.filter(t => !added.has(t.id));
|
||||
if (available.length === 0) return;
|
||||
|
||||
const devices = await devicesCache.fetch().catch((): any[] => []);
|
||||
const deviceMap: Record<string, any> = {};
|
||||
for (const d of devices) deviceMap[d.id] = d;
|
||||
|
||||
const items = available.map(t => ({
|
||||
value: t.id,
|
||||
label: t.name,
|
||||
icon: _resolveTargetIcon(t, deviceMap) || ICON_TARGET,
|
||||
icon: _resolveTargetPickerIcon(t),
|
||||
}));
|
||||
|
||||
const picked = await EntityPalette.pick({
|
||||
@@ -431,7 +475,7 @@ export async function addSceneTarget(): Promise<void> {
|
||||
|
||||
const tgt = _allTargets.find(t => t.id === picked);
|
||||
if (tgt) {
|
||||
_addTargetToList(tgt.id, tgt.name, _resolveTargetIcon(tgt, deviceMap));
|
||||
_addTargetToList(tgt);
|
||||
_autoGenerateScenePresetName();
|
||||
}
|
||||
}
|
||||
@@ -515,19 +559,15 @@ export async function cloneScenePreset(presetId: string): Promise<void> {
|
||||
if (selectorGroup && targetList) {
|
||||
selectorGroup.style.display = '';
|
||||
targetList.innerHTML = '';
|
||||
_setSceneTargetListEmptyHint(targetList);
|
||||
try {
|
||||
_allTargets = await outputTargetsCache.fetch().catch((): any[] => []);
|
||||
await _primeTargetSelector();
|
||||
|
||||
// Pre-add targets from the cloned preset
|
||||
const clonedTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
|
||||
for (const tid of clonedTargetIds) {
|
||||
const tgt = _allTargets.find(t => t.id === tid);
|
||||
if (!tgt) continue;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'scene-target-item';
|
||||
item.dataset.targetId = tid;
|
||||
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">${ICON_TRASH}</button>`;
|
||||
targetList.appendChild(item);
|
||||
_appendSceneTargetItem(tgt, targetList);
|
||||
}
|
||||
_refreshTargetSelect();
|
||||
} catch { /* ignore */ }
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
audioProcessingTemplatesCache, _cachedAudioProcessingTemplates,
|
||||
audioFilterDefsCache,
|
||||
weatherSourcesCache,
|
||||
haSourcesCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
@@ -51,6 +52,7 @@ import { TreeNav } from '../core/tree-nav.ts';
|
||||
import { updateSubTabHash } from './tabs.ts';
|
||||
import { getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
|
||||
import { createValueSourceCard } from './value-sources.ts';
|
||||
import { prefetchHAEntities } from './home-assistant-sources.ts';
|
||||
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
|
||||
import { createAssetCard, initAssetDelegation } from './assets.ts';
|
||||
import { createColorStripCard } from './color-strips/index.ts';
|
||||
@@ -364,11 +366,26 @@ export async function loadPictureSources() {
|
||||
csptCache.fetch(),
|
||||
gradientsCache.fetch(),
|
||||
weatherSourcesCache.fetch(),
|
||||
haSourcesCache.fetch(),
|
||||
audioProcessingTemplatesCache.fetch(),
|
||||
audioFilterDefsCache.data.length === 0 ? audioFilterDefsCache.fetch() : Promise.resolve(audioFilterDefsCache.data),
|
||||
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
|
||||
]);
|
||||
renderPictureSourcesList(streams);
|
||||
|
||||
// Kick off HA entity friendly-name prefetch in the background.
|
||||
// Done after the first render so a slow/offline HA source can't
|
||||
// block the Streams tab; cards re-render once names arrive.
|
||||
const haSourceIds = _cachedValueSources
|
||||
.filter(v => (v as any).source_type === 'ha_entity')
|
||||
.map(v => (v as any).ha_source_id as string);
|
||||
if (haSourceIds.length > 0) {
|
||||
prefetchHAEntities(haSourceIds).then(() => {
|
||||
// Reconcile only if the Streams tab is still mounted to avoid
|
||||
// touching DOM after the user navigated away.
|
||||
if (csValueSources.isMounted()) renderPictureSourcesList(streams);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Error loading picture sources:', error);
|
||||
@@ -542,7 +559,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: 'SOURCE',
|
||||
patchLabel: t('patch.source'),
|
||||
iconActions: [
|
||||
{ icon: ICON_TEST, onclick: `showTestStreamModal('${stream.id}')`, title: t('streams.test.title') },
|
||||
{ icon: ICON_EDIT, onclick: `editStream('${stream.id}')`, title: t('common.edit') },
|
||||
@@ -600,7 +617,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: 'TEMPLATE',
|
||||
patchLabel: t('patch.template'),
|
||||
iconActions: [
|
||||
{ icon: ICON_TEST, onclick: `showTestTemplateModal('${template.id}')`, title: t('templates.test.title') },
|
||||
{ icon: ICON_EDIT, onclick: `editTemplate('${template.id}')`, title: t('common.edit') },
|
||||
@@ -646,7 +663,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: 'PIPELINE',
|
||||
patchLabel: t('patch.pipeline'),
|
||||
iconActions: [
|
||||
{ icon: ICON_TEST, onclick: `showTestPPTemplateModal('${tmpl.id}')`, title: t('postprocessing.test.title') },
|
||||
{ icon: ICON_EDIT, onclick: `editPPTemplate('${tmpl.id}')`, title: t('common.edit') },
|
||||
@@ -691,7 +708,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: 'PIPELINE',
|
||||
patchLabel: t('patch.pipeline'),
|
||||
iconActions: [
|
||||
{ icon: ICON_TEST, onclick: `event.stopPropagation(); testCSPT('${tmpl.id}')`, title: t('color_strip.test.title') },
|
||||
{ icon: ICON_EDIT, onclick: `editCSPT('${tmpl.id}')`, title: t('common.edit') },
|
||||
@@ -887,7 +904,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: 'SOURCE',
|
||||
patchLabel: t('patch.source'),
|
||||
iconActions: [
|
||||
{ icon: ICON_TEST, onclick: '', title: t('audio_source.test'), dataAttrs: { 'data-action': 'test-audio' } },
|
||||
{ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit-audio' } },
|
||||
@@ -944,7 +961,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: 'TEMPLATE',
|
||||
patchLabel: t('patch.template'),
|
||||
iconActions: [
|
||||
{ icon: ICON_TEST, onclick: `showTestAudioTemplateModal('${template.id}')`, title: t('audio_template.test') },
|
||||
{ icon: ICON_EDIT, onclick: `editAudioTemplate('${template.id}')`, title: t('common.edit') },
|
||||
@@ -989,7 +1006,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: 'PRESET',
|
||||
patchLabel: t('patch.preset'),
|
||||
iconActions,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,6 +19,8 @@ import { Modal } from '../core/modal.ts';
|
||||
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.ts';
|
||||
import { _splitOpenrgbZone } from './device-discovery.ts';
|
||||
import { createHALightTargetCard, initHALightTargetDelegation, patchHALightTargetMetrics, connectHALightWS, disconnectHALightWS } from './ha-light-targets.ts';
|
||||
import { createZ2MLightTargetCard, initZ2MLightTargetDelegation, patchZ2MLightTargetMetrics, connectZ2MLightWS, disconnectZ2MLightWS } from './z2m-light-targets.ts';
|
||||
import { prefetchHAEntities } from './home-assistant-sources.ts';
|
||||
import {
|
||||
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
|
||||
ICON_EDIT, ICON_START, ICON_STOP,
|
||||
@@ -100,6 +102,7 @@ const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.de
|
||||
] });
|
||||
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, bulkActions: _targetBulkActions });
|
||||
const csHALightTargets = new CardSection('ha-light-targets', { titleKey: 'ha_light.section.title', gridClass: 'devices-grid', addCardOnclick: "showHALightEditor()", keyAttr: 'data-ha-target-id', emptyKey: 'section.empty.ha_light_targets', bulkActions: _targetBulkActions });
|
||||
const csZ2MLightTargets = new CardSection('z2m-light-targets', { titleKey: 'z2m_light.section.title', gridClass: 'devices-grid', addCardOnclick: "showZ2MLightEditor()", keyAttr: 'data-z2m-target-id', emptyKey: 'section.empty.z2m_light_targets', bulkActions: _targetBulkActions });
|
||||
|
||||
// Re-render targets tab when language changes (only if tab is active)
|
||||
document.addEventListener('languageChanged', () => {
|
||||
@@ -648,6 +651,7 @@ export async function loadTargetsTab() {
|
||||
const ledDevices = devicesWithState;
|
||||
const ledTargets = targetsWithState.filter((t): t is LedOutputTarget & { state?: any; metrics?: any } => t.target_type === 'led' || (t.target_type as string) === 'wled');
|
||||
const haLightTargets = targetsWithState.filter(t => t.target_type === 'ha_light');
|
||||
const z2mLightTargets = targetsWithState.filter(t => t.target_type === 'z2m_light');
|
||||
|
||||
// Update tab badge with running target count
|
||||
const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length;
|
||||
@@ -669,10 +673,16 @@ export async function loadTargetsTab() {
|
||||
children: [
|
||||
{ key: 'ha-light-targets', titleKey: 'ha_light.section.targets', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: haLightTargets.length },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'z2m_light_group', icon: `<svg class="icon" viewBox="0 0 24 24">${P.lightbulb}</svg>`, titleKey: 'z2m_light.section.title',
|
||||
children: [
|
||||
{ key: 'z2m-light-targets', titleKey: 'z2m_light.section.targets', icon: `<svg class="icon" viewBox="0 0 24 24">${P.lightbulb}</svg>`, count: z2mLightTargets.length },
|
||||
]
|
||||
}
|
||||
];
|
||||
// Determine which tree leaf is active — migrate old values
|
||||
const validLeaves = ['led-devices', 'led-targets', 'ha-light-targets'];
|
||||
const validLeaves = ['led-devices', 'led-targets', 'ha-light-targets', 'z2m-light-targets'];
|
||||
const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab : 'led-devices';
|
||||
|
||||
// Build items arrays for each section (apply saved drag order)
|
||||
@@ -681,6 +691,7 @@ export async function loadTargetsTab() {
|
||||
const haSourceMap: Record<string, any> = {};
|
||||
_cachedHASources.forEach(s => { haSourceMap[s.id] = s; });
|
||||
const haLightTargetItems = csHALightTargets.applySortOrder(haLightTargets.map(t => ({ key: t.id, html: createHALightTargetCard(t, haSourceMap, colorStripSourceMap, valueSourceMap) })));
|
||||
const z2mLightTargetItems = csZ2MLightTargets.applySortOrder(z2mLightTargets.map(t => ({ key: t.id, html: createZ2MLightTargetCard(t, colorStripSourceMap, valueSourceMap) })));
|
||||
|
||||
// Track which target cards were replaced/added (need chart re-init)
|
||||
let changedTargetIds: Set<string> | null = null;
|
||||
@@ -691,10 +702,12 @@ export async function loadTargetsTab() {
|
||||
'led-devices': ledDevices.length,
|
||||
'led-targets': ledTargets.length,
|
||||
'ha-light-targets': haLightTargets.length,
|
||||
'z2m-light-targets': z2mLightTargets.length,
|
||||
});
|
||||
csDevices.reconcile(deviceItems);
|
||||
const ledResult = csLedTargets.reconcile(ledTargetItems);
|
||||
csHALightTargets.reconcile(haLightTargetItems);
|
||||
csZ2MLightTargets.reconcile(z2mLightTargetItems);
|
||||
changedTargetIds = new Set<string>([...(ledResult.added as unknown as string[]), ...(ledResult.replaced as unknown as string[]), ...(ledResult.removed as unknown as string[])]);
|
||||
|
||||
// Restore LED preview state on replaced cards (panel hidden by default in HTML)
|
||||
@@ -709,10 +722,12 @@ export async function loadTargetsTab() {
|
||||
{ key: 'led-devices', html: csDevices.render(deviceItems) },
|
||||
{ key: 'led-targets', html: csLedTargets.render(ledTargetItems) },
|
||||
{ key: 'ha-light-targets', html: csHALightTargets.render(haLightTargetItems) },
|
||||
{ key: 'z2m-light-targets', html: csZ2MLightTargets.render(z2mLightTargetItems) },
|
||||
].map(p => `<div class="target-sub-tab-panel stream-tab-panel${p.key === activeLeaf ? ' active' : ''}" id="target-sub-tab-${p.key}">${p.html}</div>`).join('');
|
||||
container.innerHTML = panels;
|
||||
CardSection.bindAll([csDevices, csLedTargets, csHALightTargets]);
|
||||
CardSection.bindAll([csDevices, csLedTargets, csHALightTargets, csZ2MLightTargets]);
|
||||
initHALightTargetDelegation(container);
|
||||
initZ2MLightTargetDelegation(container);
|
||||
|
||||
// Render tree sidebar with expand/collapse buttons
|
||||
_targetsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
||||
@@ -732,6 +747,22 @@ export async function loadTargetsTab() {
|
||||
for (const tgt of haLightTargets) {
|
||||
if (tgt.state && tgt.state.processing) patchHALightTargetMetrics(tgt);
|
||||
}
|
||||
for (const tgt of z2mLightTargets) {
|
||||
if (tgt.state && tgt.state.processing) patchZ2MLightTargetMetrics(tgt);
|
||||
}
|
||||
|
||||
// Background prefetch of HA entity friendly names for ha_light
|
||||
// targets. Once names arrive, re-patch swatches so each light gets
|
||||
// its friendly label instead of the raw `light.foo` entity_id.
|
||||
const haLightSourceIds = haLightTargets.map(t => (t as any).ha_source_id).filter(Boolean);
|
||||
if (haLightSourceIds.length > 0) {
|
||||
prefetchHAEntities(haLightSourceIds).then(() => {
|
||||
if (!csHALightTargets.isMounted()) return;
|
||||
for (const tgt of haLightTargets) {
|
||||
if (tgt.state && tgt.state.processing) patchHALightTargetMetrics(tgt);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Attach event listeners and fetch brightness for device cards
|
||||
devicesWithState.forEach(device => {
|
||||
@@ -786,6 +817,15 @@ export async function loadTargetsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Manage Z2M light colour preview WebSockets
|
||||
z2mLightTargets.forEach(target => {
|
||||
if (target.state && target.state.processing) {
|
||||
connectZ2MLightWS(target.id);
|
||||
} else {
|
||||
disconnectZ2MLightWS(target.id);
|
||||
}
|
||||
});
|
||||
|
||||
// FPS charts: only destroy charts for replaced/removed cards (or all on first render)
|
||||
if (changedTargetIds) {
|
||||
// Incremental: destroy only charts whose cards were replaced or removed
|
||||
@@ -1139,24 +1179,24 @@ export function createTargetCard(target: LedOutputTarget & { state?: any; metric
|
||||
isProcessing ? (state.device_streaming_reachable === false ? 'offline' : 'live') :
|
||||
devOnline ? 'standby' : 'offline';
|
||||
const patchLabel = lastChecked == null
|
||||
? (t('device.health.checking') || 'Checking').toUpperCase()
|
||||
? t('patch.checking')
|
||||
: isProcessing
|
||||
? (state.device_streaming_reachable === false ? 'UNREACHABLE' : 'STREAMING')
|
||||
? (state.device_streaming_reachable === false ? t('patch.unreachable') : t('patch.streaming'))
|
||||
: devOnline
|
||||
? 'STANDBY'
|
||||
: (t('device.health.offline') || 'Offline').toUpperCase();
|
||||
? t('patch.standby')
|
||||
: t('patch.offline');
|
||||
|
||||
// ── Foot actions ──
|
||||
const primaryAction: ModBtnOpts = isProcessing
|
||||
? {
|
||||
label: 'STOP',
|
||||
label: t('common.stop'),
|
||||
icon: ICON_STOP,
|
||||
onclick: `stopTargetProcessing('${target.id}')`,
|
||||
title: t('device.button.stop'),
|
||||
variant: 'stop',
|
||||
}
|
||||
: {
|
||||
label: 'START',
|
||||
label: t('common.start'),
|
||||
icon: ICON_START,
|
||||
onclick: `startTargetProcessing('${target.id}')`,
|
||||
title: t('device.button.start'),
|
||||
@@ -1168,7 +1208,7 @@ export function createTargetCard(target: LedOutputTarget & { state?: any; metric
|
||||
iconActions.push({
|
||||
icon: ICON_LED_PREVIEW,
|
||||
onclick: `toggleLedPreview('${target.id}')`,
|
||||
title: 'LED Preview',
|
||||
title: t('common.led_preview'),
|
||||
dataAttrs: { 'data-led-preview-btn': target.id },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
_cachedHASources, _cachedColorStripSources, gradientsCache, GradientEntity,
|
||||
_cachedGameIntegrations, _cachedGameAdapters, gameIntegrationsCache, gameAdaptersCache,
|
||||
_cachedSyncClocks, syncClocksCache,
|
||||
getHAEntityFriendlyName, setHAEntityNames,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
@@ -968,8 +969,13 @@ let _testVsColorLatest: [number, number, number] | null = null;
|
||||
let _testVsColorHistory: [number, number, number][] = [];
|
||||
|
||||
const testVsModal = new Modal('test-value-source-modal', { backdrop: true, lock: true });
|
||||
testVsModal.onForceClose = () => _cleanupVsTest();
|
||||
|
||||
export async function testValueSource(sourceId: any) {
|
||||
// Tear down any prior stream before resetting state so a stale onmessage
|
||||
// handler (e.g. from a backdrop/Esc close) can't pollute the new history.
|
||||
_cleanupVsTest();
|
||||
|
||||
const statusEl = document.getElementById('vs-test-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = t('value_source.test.connecting');
|
||||
@@ -1071,7 +1077,11 @@ function _cleanupVsTest() {
|
||||
_testVsAnimFrame = null;
|
||||
}
|
||||
if (_testVsWs) {
|
||||
// Drop all handlers before close() so in-flight frames arriving while
|
||||
// the socket is in CLOSING state can't push into the shared history.
|
||||
_testVsWs.onmessage = null;
|
||||
_testVsWs.onclose = null;
|
||||
_testVsWs.onerror = null;
|
||||
_testVsWs.close();
|
||||
_testVsWs = null;
|
||||
}
|
||||
@@ -1355,17 +1365,25 @@ function _valueSourceChipsAndExtras(src: ValueSource): { chips: ModChipOpts[]; m
|
||||
chips.push({ icon: ICON_MAP_PIN, text: `${pts} ${t('value_source.schedule.points')}` });
|
||||
metaText = `${pts} schedule pts`;
|
||||
} else if (src.source_type === 'ha_entity') {
|
||||
const haSrc = _cachedHASources.find(h => h.id === (src as any).ha_source_id);
|
||||
const haName = haSrc ? haSrc.name : ((src as any).ha_source_id || '-');
|
||||
const haSourceId = (src as any).ha_source_id;
|
||||
const haSrc = _cachedHASources.find(h => h.id === haSourceId);
|
||||
const haName = haSrc ? haSrc.name : (haSourceId || '-');
|
||||
const entityId = (src as any).entity_id || '';
|
||||
const attr = (src as any).attribute;
|
||||
// Prefer the cached friendly name; fall back to the entity_id if the
|
||||
// cache hasn't been populated yet (e.g. HA source offline at load).
|
||||
const friendlyName = getHAEntityFriendlyName(haSourceId, entityId);
|
||||
const entityLabel = `${friendlyName}${attr ? '.' + attr : ''}`;
|
||||
const entityTooltip = friendlyName !== entityId && entityId
|
||||
? `${entityId}${attr ? '.' + attr : ''}`
|
||||
: undefined;
|
||||
chips.push({
|
||||
icon: ICON_HOME, text: haName, title: t('value_source.ha_source'),
|
||||
onclick: haSrc ? `event.stopPropagation(); navigateToCard('integrations','home_assistant','ha-sources','data-id','${(src as any).ha_source_id}')` : undefined,
|
||||
onclick: haSrc ? `event.stopPropagation(); navigateToCard('integrations','home_assistant','ha-sources','data-id','${haSourceId}')` : undefined,
|
||||
});
|
||||
chips.push({ icon: ICON_LINK, text: `${entityId}${attr ? '.' + attr : ''}` });
|
||||
chips.push({ icon: ICON_LINK, text: entityLabel, title: entityTooltip });
|
||||
chips.push({ icon: ICON_MOVE_VERTICAL, text: `${(src as any).min_ha_value ?? 0}\u2013${(src as any).max_ha_value ?? 100}` });
|
||||
metaText = `${haName} \u00b7 ${entityId}`;
|
||||
metaText = `${haName} \u00b7 ${friendlyName}`;
|
||||
} else if (src.source_type === 'gradient_map') {
|
||||
const inputVs = _cachedValueSources.find(v => v.id === (src as any).value_source_id);
|
||||
const inputName = inputVs ? inputVs.name : ((src as any).value_source_id || '-');
|
||||
@@ -1431,7 +1449,7 @@ export function createValueSourceCard(src: ValueSource) {
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: 'VALUE',
|
||||
patchLabel: t('patch.value'),
|
||||
iconActions: [
|
||||
{ icon: ICON_TEST, onclick: `testValueSource('${src.id}')`, title: t('value_source.test') },
|
||||
{ icon: ICON_EDIT, onclick: `editValueSource('${src.id}')`, title: t('common.edit') },
|
||||
@@ -1750,6 +1768,9 @@ async function _fetchVSHAEntities(haSourceId: string): Promise<void> {
|
||||
if (!resp.ok) { _vsHAEntities = []; return; }
|
||||
const data = await resp.json();
|
||||
_vsHAEntities = data.entities || [];
|
||||
// Refresh the shared friendly-name cache so the value-source cards
|
||||
// pick up new entity names the next time the list re-renders.
|
||||
setHAEntityNames(haSourceId, _vsHAEntities);
|
||||
} catch {
|
||||
_vsHAEntities = [];
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ export function createWeatherSourceCard(source: WeatherSource) {
|
||||
},
|
||||
foot: {
|
||||
patchState: 'live',
|
||||
patchLabel: 'POLLING',
|
||||
patchLabel: t('patch.polling'),
|
||||
iconActions: [
|
||||
{ icon: ICON_TEST, onclick: '', title: t('weather_source.test'), dataAttrs: { 'data-action': 'test' } },
|
||||
{ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } },
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
/**
|
||||
* Z2M Light Targets — editor/cards/CRUD for Zigbee2MQTT light output targets.
|
||||
*
|
||||
* Mirrors the HA Light Target editor visually and structurally:
|
||||
* - BindableScalarWidget for all numeric fields
|
||||
* - EntitySelect for the unified colour source picker (CSS + color VS)
|
||||
* - IconSelect for the stop-action picker
|
||||
* - Per-source-kind mapping rows (LED ranges in CSS mode, brightness-only in color_vs mode)
|
||||
*
|
||||
* Friendly names are typed text (no Z2M device catalog yet) — the row chrome
|
||||
* is otherwise identical to HA mappings so the modals feel like siblings.
|
||||
*/
|
||||
|
||||
import {
|
||||
_cachedValueSources, _cachedMQTTSources,
|
||||
colorStripSourcesCache, mqttSourcesCache,
|
||||
outputTargetsCache, valueSourcesCache,
|
||||
} 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_STOP_PLAIN, ICON_TRASH,
|
||||
ICON_OK, ICON_WARNING, ICON_CLOCK, ICON_SUN,
|
||||
getColorStripIcon, getValueSourceIcon,
|
||||
} 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 { 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';
|
||||
|
||||
const ICON_Z2M = `<svg class="icon" viewBox="0 0 24 24">${P.lightbulb}</svg>`;
|
||||
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
// ── Modal state ──
|
||||
|
||||
type Z2MLightSourceKind = 'css' | 'color_vs';
|
||||
|
||||
let _z2mLightTagsInput: TagInput | null = null;
|
||||
let _colorSourceEntitySelect: EntitySelect | null = null;
|
||||
let _mqttSourceEntitySelect: EntitySelect | null = null;
|
||||
let _stopActionIconSelect: IconSelect | null = null;
|
||||
let _editorCssSources: any[] = [];
|
||||
let _editorColorValueSources: any[] = []; // value sources where return_type === 'color'
|
||||
let _editorSourceKind: Z2MLightSourceKind = 'css';
|
||||
let _editorColorVsId: string = '';
|
||||
|
||||
// Shared bundle of the 5 BindableScalarWidget knobs. Z2M overrides
|
||||
// updateRate (0.5-10 Hz vs HA's 0.5-5) and transition (default 0.3s vs HA's 0.5s).
|
||||
const _widgets = new LightTargetWidgets({
|
||||
idPrefix: 'z2m-light-editor',
|
||||
valueSources: () => _cachedValueSources,
|
||||
overrides: {
|
||||
updateRate: { max: 10.0, step: 0.5, default: 5.0 },
|
||||
transition: { default: 0.3 },
|
||||
},
|
||||
});
|
||||
|
||||
class Z2MLightEditorModal extends Modal {
|
||||
constructor() { super('z2m-light-editor-modal'); }
|
||||
|
||||
onForceClose() {
|
||||
if (_z2mLightTagsInput) { _z2mLightTagsInput.destroy(); _z2mLightTagsInput = null; }
|
||||
if (_colorSourceEntitySelect) { _colorSourceEntitySelect.destroy(); _colorSourceEntitySelect = null; }
|
||||
if (_mqttSourceEntitySelect) { _mqttSourceEntitySelect.destroy(); _mqttSourceEntitySelect = null; }
|
||||
if (_stopActionIconSelect) { _stopActionIconSelect.destroy(); _stopActionIconSelect = null; }
|
||||
_widgets.destroyAll();
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: (document.getElementById('z2m-light-editor-name') as HTMLInputElement).value,
|
||||
mqtt_source: (document.getElementById('z2m-light-editor-mqtt-source') as HTMLSelectElement).value,
|
||||
base_topic: (document.getElementById('z2m-light-editor-base-topic') as HTMLInputElement).value,
|
||||
color_source: (document.getElementById('z2m-light-editor-color-source') as HTMLSelectElement).value,
|
||||
source_kind: _editorSourceKind,
|
||||
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('z2m-light-editor-stop-action') as HTMLSelectElement).value,
|
||||
mappings: _getMappingsJSON(),
|
||||
tags: JSON.stringify(_z2mLightTagsInput ? _z2mLightTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const z2mLightEditorModal = new Z2MLightEditorModal();
|
||||
|
||||
// ── Mappings ──
|
||||
|
||||
function _getMappingsJSON(): string {
|
||||
const rows = document.querySelectorAll('#z2m-light-mappings-list .ha-light-mapping-row');
|
||||
const mappings: any[] = [];
|
||||
rows.forEach(row => {
|
||||
const friendlyEl = row.querySelector('.z2m-mapping-friendly') as HTMLInputElement;
|
||||
const ledStartEl = row.querySelector('.z2m-mapping-led-start') as HTMLInputElement | null;
|
||||
const ledEndEl = row.querySelector('.z2m-mapping-led-end') as HTMLInputElement | null;
|
||||
const brightnessEl = row.querySelector('.z2m-mapping-brightness') as HTMLInputElement;
|
||||
// In color_vs mode LED range inputs aren't rendered — fall back to
|
||||
// safe defaults so mappings round-trip if the user toggles back to CSS.
|
||||
mappings.push({
|
||||
friendly_name: friendlyEl ? friendlyEl.value.trim() : '',
|
||||
led_start: ledStartEl ? (parseInt(ledStartEl.value) || 0) : 0,
|
||||
led_end: ledEndEl ? (parseInt(ledEndEl.value) || -1) : -1,
|
||||
brightness_scale: parseFloat(brightnessEl.value) || 1.0,
|
||||
});
|
||||
});
|
||||
return JSON.stringify(mappings);
|
||||
}
|
||||
|
||||
function _isColorValueSource(vs: any): boolean {
|
||||
return !!vs && vs.return_type === 'color';
|
||||
}
|
||||
|
||||
/** Build the unified Color Source picker items via the shared helper. */
|
||||
function _buildColorSourcePickerItems(): any[] {
|
||||
return buildColorSourcePickerItems(
|
||||
_editorCssSources,
|
||||
_editorColorValueSources,
|
||||
{
|
||||
cssHeader: t('z2m_light.color_source.css'),
|
||||
colorVsHeader: t('z2m_light.color_source.color_vs'),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function _setMappingsModeHint(): void {
|
||||
const hintColorVs = document.getElementById('z2m-light-mappings-mode-hint');
|
||||
const hintCss = document.querySelectorAll('#z2m-light-editor-modal small.input-hint[data-i18n="z2m_light.mappings.hint"]')[0] as HTMLElement | undefined;
|
||||
const visibleCss = _editorSourceKind === 'css';
|
||||
if (hintColorVs) hintColorVs.style.display = visibleCss ? 'none' : '';
|
||||
// CSS hint is only revealed by the toggle; do not auto-show.
|
||||
if (hintCss && !visibleCss) hintCss.style.display = 'none';
|
||||
}
|
||||
|
||||
export function addZ2MLightMapping(data: any = null): void {
|
||||
const list = document.getElementById('z2m-light-mappings-list');
|
||||
if (!list) return;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ha-light-mapping-row';
|
||||
|
||||
const friendlyVal = data?.friendly_name || '';
|
||||
const previewIsEmpty = !friendlyVal;
|
||||
const previewText = friendlyVal || (t('z2m_light.mapping.unassigned') || '—');
|
||||
|
||||
// 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'
|
||||
? `<div class="ha-mapping-range-row">
|
||||
<div>
|
||||
<label>${t('z2m_light.mapping.brightness')}</label>
|
||||
<input type="number" class="z2m-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
|
||||
</div>
|
||||
</div>`
|
||||
: `<div class="ha-mapping-range-row">
|
||||
<div>
|
||||
<label>${t('z2m_light.mapping.led_start')}</label>
|
||||
<input type="number" class="z2m-mapping-led-start" value="${data?.led_start ?? 0}" min="0" step="1">
|
||||
</div>
|
||||
<div>
|
||||
<label>${t('z2m_light.mapping.led_end')}</label>
|
||||
<input type="number" class="z2m-mapping-led-end" value="${data?.led_end ?? -1}" min="-1" step="1">
|
||||
</div>
|
||||
<div>
|
||||
<label>${t('z2m_light.mapping.brightness')}</label>
|
||||
<input type="number" class="z2m-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const removeLabel = t('common.delete') || 'Remove';
|
||||
row.innerHTML = `
|
||||
<div class="ha-mapping-header">
|
||||
<div class="ha-mapping-icon" aria-hidden="true">${_icon(P.lightbulb)}</div>
|
||||
<span class="ha-mapping-name${previewIsEmpty ? ' is-empty' : ''}">${escapeHtml(previewText)}</span>
|
||||
<button type="button" class="ha-mapping-remove-btn" onclick="removeZ2MLightMapping(this)" title="${escapeHtml(removeLabel)}" aria-label="${escapeHtml(removeLabel)}">${ICON_TRASH}</button>
|
||||
</div>
|
||||
<div class="ha-mapping-fields">
|
||||
<div class="ha-mapping-field">
|
||||
<label>${t('z2m_light.mapping.friendly_name')}</label>
|
||||
<input type="text" class="z2m-mapping-friendly" value="${escapeHtml(friendlyVal)}" placeholder="living_room_bulb_1" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
${rangeBlock}
|
||||
</div>
|
||||
`;
|
||||
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<void> {
|
||||
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 = `<option value="">${t('z2m_light.mqtt_source.none')}</option>` +
|
||||
mqttSources.map((s: any) =>
|
||||
`<option value="${escapeHtml(s.id)}">${escapeHtml(s.name)}</option>`
|
||||
).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) =>
|
||||
`<option value="css:${escapeHtml(s.id)}">${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
const colorVsOptions = _editorColorValueSources.map((s: any) =>
|
||||
`<option value="cvs:${escapeHtml(s.id)}">${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
colorSelect.innerHTML = `<option value="">—</option>${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:<id>" or "cvs:<id>" 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<void> {
|
||||
await z2mLightEditorModal.close();
|
||||
}
|
||||
|
||||
// ── Save ──
|
||||
|
||||
export async function saveZ2MLightEditor(): Promise<void> {
|
||||
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<void> {
|
||||
await showZ2MLightEditor(targetId);
|
||||
}
|
||||
|
||||
export async function cloneZ2MLightTarget(targetId: string): Promise<void> {
|
||||
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<string, any> = {}, valueSourceMap: Record<string, any> = {}): 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: '<span data-tm="fps">---</span>', accent: true, title: t('targets.fps') });
|
||||
metrics.push({ k: t('device.metrics.uptime') || 'Uptime', icon: ICON_CLOCK, v: '<span data-tm="uptime">---</span>', title: t('device.metrics.uptime') });
|
||||
metrics.push({
|
||||
k: 'MQTT',
|
||||
icon: state.mqtt_connected ? ICON_OK : ICON_WARNING,
|
||||
v: '<span data-tm="mqtt-status">---</span>',
|
||||
title: t('z2m_light.connection_metric'),
|
||||
});
|
||||
}
|
||||
|
||||
const extraHtml = isRunning ? `<div class="ha-light-swatches" data-z2m-swatches="${target.id}"></div>` : '';
|
||||
|
||||
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}</div>`) : 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<string, (id: string) => 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<void> {
|
||||
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<void> {
|
||||
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<HTMLElement>('[data-action]');
|
||||
if (!btn) return;
|
||||
const section = btn.closest<HTMLElement>('[data-card-section="z2m-light-targets"]');
|
||||
if (!section) return;
|
||||
const card = btn.closest<HTMLElement>('[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<string, any>, mappings: any[]): string {
|
||||
if (!mappings.length) return '';
|
||||
return mappings.map(m => {
|
||||
const c = entityColors[m.friendly_name];
|
||||
const bg = c ? c.hex : '#333';
|
||||
return `<div class="ha-light-swatch" data-entity="${escapeHtml(m.friendly_name)}" title="${escapeHtml(m.friendly_name)}">
|
||||
<span class="swatch-color" style="background:${bg}"></span>
|
||||
<span class="swatch-label">${escapeHtml(m.friendly_name)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── WebSocket color preview ──
|
||||
|
||||
const _z2mLightWS: Record<string, WebSocket> = {};
|
||||
|
||||
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<string, any>): 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;
|
||||
@@ -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<string, { sound_asset_id?: string | null; volume?: number }>;
|
||||
|
||||
// Daylight
|
||||
use_real_time?: boolean;
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "Обычное",
|
||||
|
||||
@@ -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": "正常",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
# 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)
|
||||
|
||||
@@ -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,31 +484,27 @@ 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(
|
||||
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,
|
||||
brightness_value_source_id=brightness_value_source_id,
|
||||
fps=fps,
|
||||
keepalive_interval=keepalive_interval,
|
||||
state_check_interval=state_check_interval,
|
||||
@@ -208,21 +515,52 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
|
||||
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,
|
||||
light_mappings=ha_light_mappings,
|
||||
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,
|
||||
)
|
||||
|
||||
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, 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:
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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/<friendly_name>/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 ``<base_topic>/<friendly_name>/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())
|
||||
),
|
||||
)
|
||||
@@ -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' %}
|
||||
|
||||
@@ -225,7 +225,7 @@
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.composite.layers.hint">Stack multiple color strip sources. First layer is the bottom, last is the top.</small>
|
||||
<div id="composite-layers-list"></div>
|
||||
<button type="button" class="btn btn-secondary" onclick="compositeAddLayer()" data-i18n="color_strip.composite.add_layer">+ Add Layer</button>
|
||||
<button type="button" class="composite-add-layer-slot" onclick="compositeAddLayer()"><span data-i18n="color_strip.composite.add_layer">Add Layer</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -72,9 +72,7 @@
|
||||
<small class="input-hint" style="display:none" data-i18n="ha_light.mappings.hint">Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.</small>
|
||||
<small id="ha-light-mappings-mode-hint" class="input-hint" style="display:none" data-i18n="ha_light.mappings.color_vs_hint">All listed lights will receive the same colour from the selected Color Value Source.</small>
|
||||
<div id="ha-light-mappings-list"></div>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="addHALightMapping()" style="margin-top: 4px;">
|
||||
+ <span data-i18n="ha_light.mappings.add">Add Mapping</span>
|
||||
</button>
|
||||
<button type="button" class="ha-mapping-add-slot" onclick="addHALightMapping()"><span data-i18n="ha_light.mappings.add">Add Mapping</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="scenes.targets.hint">Select which targets to include in this scene snapshot</small>
|
||||
<div id="scene-target-list" class="scene-target-list"></div>
|
||||
<button type="button" id="scene-target-add-btn" class="btn btn-sm btn-secondary" onclick="addSceneTarget()" style="margin-top: 6px;">+ <span data-i18n="scenes.targets.add">Add Target</span></button>
|
||||
<div id="scene-target-list" class="scene-target-list" data-empty="No targets selected"></div>
|
||||
<button type="button" id="scene-target-add-btn" class="scene-target-add-slot" onclick="addSceneTarget()"><span data-i18n="scenes.targets.add">Add Target</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<!-- Z2M Light Target Editor Modal — mirrors ha-light-editor.html section
|
||||
vocabulary and chrome (rack-panel layout with channel-coloured indices):
|
||||
Identity (signal) — name + description + tags
|
||||
Routing (cyan) — base topic + colour source + bulb mappings
|
||||
Output (amber) — update rate + transition + brightness + stop action
|
||||
Filtering (violet) — colour tolerance + min brightness threshold
|
||||
All numeric fields use BindableScalarWidget, the stop-action select is
|
||||
upgraded by IconSelect, and the colour source select is wrapped by
|
||||
EntitySelect — same widget treatment as the HA-light editor. -->
|
||||
<div id="z2m-light-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="z2m-light-editor-title">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="z2m-light-editor-title" data-i18n="z2m_light.add">Add Z2M Light Target</h2>
|
||||
<button class="modal-close-btn" onclick="closeZ2MLightEditor()" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="z2m-light-editor-form" onsubmit="return false;">
|
||||
<input type="hidden" id="z2m-light-editor-id">
|
||||
|
||||
<div id="z2m-light-editor-error" class="error-message" style="display: none;"></div>
|
||||
|
||||
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
|
||||
<section class="ds-section" data-ds-key="identity" data-ch="signal">
|
||||
<div class="ds-section-header">
|
||||
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
|
||||
<span class="ds-section-index" aria-hidden="true">01</span>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
<div class="form-group ds-name-group">
|
||||
<label for="z2m-light-editor-name" data-i18n="z2m_light.name">Name:</label>
|
||||
<input type="text" id="z2m-light-editor-name" data-i18n-placeholder="z2m_light.name.placeholder" placeholder="Living Room Bulbs" required>
|
||||
<div id="z2m-light-tags-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="z2m-light-editor-description" data-i18n="z2m_light.description">Description (optional):</label>
|
||||
</div>
|
||||
<input type="text" id="z2m-light-editor-description" placeholder="">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 02 · ROUTING ────────────────────────────────── -->
|
||||
<section class="ds-section" data-ds-key="routing" data-ch="cyan">
|
||||
<div class="ds-section-header">
|
||||
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||
<span class="ds-section-title" data-i18n="settings.section.routing">Routing</span>
|
||||
<span class="ds-section-index" aria-hidden="true">02</span>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="z2m-light-editor-mqtt-source" data-i18n="z2m_light.mqtt_source">MQTT Broker:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="z2m_light.mqtt_source.hint">Pick the MQTT source (broker) that Zigbee2MQTT is connected to. Manage brokers under MQTT Sources.</small>
|
||||
<select id="z2m-light-editor-mqtt-source"></select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="z2m-light-editor-base-topic" data-i18n="z2m_light.base_topic">Z2M Base Topic:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="z2m_light.base_topic.hint">Override only if your Zigbee2MQTT instance uses a non-default mqtt.base_topic.</small>
|
||||
<input type="text" id="z2m-light-editor-base-topic" placeholder="zigbee2mqtt" value="zigbee2mqtt">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="z2m-light-editor-color-source" data-i18n="z2m_light.color_source">Color Source:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="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).</small>
|
||||
<select id="z2m-light-editor-color-source"></select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="z2m_light.mappings">Bulb Mappings:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="z2m_light.mappings.hint">Map LED ranges to Z2M friendly names. Each mapping averages the LED segment to a single colour.</small>
|
||||
<small id="z2m-light-mappings-mode-hint" class="input-hint" style="display:none" data-i18n="z2m_light.mappings.color_vs_hint">All listed bulbs will receive the same colour from the selected Color Value Source.</small>
|
||||
<div id="z2m-light-mappings-list"></div>
|
||||
<button type="button" class="ha-mapping-add-slot" onclick="addZ2MLightMapping()"><span data-i18n="z2m_light.mappings.add">Add Mapping</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 03 · OUTPUT ─────────────────────────────────── -->
|
||||
<section class="ds-section" data-ds-key="output" data-ch="amber">
|
||||
<div class="ds-section-header">
|
||||
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||
<span class="ds-section-title" data-i18n="settings.section.output">Output</span>
|
||||
<span class="ds-section-index" aria-hidden="true">03</span>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label>
|
||||
<span data-i18n="z2m_light.update_rate">Update Rate:</span>
|
||||
</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="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.</small>
|
||||
<div id="z2m-light-editor-update-rate-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label>
|
||||
<span data-i18n="z2m_light.transition">Transition:</span>
|
||||
</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="z2m_light.transition.hint">Smooth fade duration between colours, in seconds (Z2M transition parameter).</small>
|
||||
<div id="z2m-light-editor-transition-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="targets.brightness">Brightness:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="targets.brightness.hint">Output brightness multiplier (0–1). Can be bound to a value source for dynamic control.</small>
|
||||
<div id="z2m-light-editor-brightness-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="z2m-light-editor-stop-action" data-i18n="z2m_light.stop_action">On Stop:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="z2m_light.stop_action.hint">What to do with the mapped bulbs when this target stops streaming.</small>
|
||||
<select id="z2m-light-editor-stop-action"></select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 04 · FILTERING ──────────────────────────────── -->
|
||||
<section class="ds-section" data-ds-key="filtering" data-ch="violet">
|
||||
<div class="ds-section-header">
|
||||
<span class="ds-section-dot" aria-hidden="true"></span>
|
||||
<span class="ds-section-title" data-i18n="settings.section.filtering">Filtering</span>
|
||||
<span class="ds-section-index" aria-hidden="true">04</span>
|
||||
</div>
|
||||
<div class="ds-section-body">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label>
|
||||
<span data-i18n="z2m_light.color_tolerance">Color Tolerance:</span>
|
||||
</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="z2m_light.color_tolerance.hint">Skip publishes when the RGB delta is below this threshold. Reduces Zigbee mesh traffic for near-static scenes.</small>
|
||||
<div id="z2m-light-editor-color-tolerance-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label>
|
||||
<span data-i18n="z2m_light.min_brightness_threshold">Min Brightness Threshold:</span>
|
||||
</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="z2m_light.min_brightness_threshold.hint">Effective output brightness below this value turns bulbs off completely (0 = disabled).</small>
|
||||
<div id="z2m-light-editor-min-brightness-threshold-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeZ2MLightEditor()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveZ2MLightEditor()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user