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:
2026-05-12 18:06:09 +03:00
parent 6e4c1b6642
commit 530316c2c3
60 changed files with 5187 additions and 1025 deletions
+130 -1
View File
@@ -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.
+43 -11
View File
@@ -1,20 +1,52 @@
"""LED Grab - Ambient lighting based on screen content."""
from importlib.metadata import version, PackageNotFoundError
from pathlib import Path
# Fallback version — kept in sync with pyproject.toml. MUST match the
# version declared there on every release. The Windows installer build
# (build/build-dist.ps1) also patches this literal to the resolved build
# version, so any drift here is corrected for bundled distributions.
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy
# on Android, where the source is included directly via source sets, or
# in the Windows bundle where the installed dist-info is stripped).
# Fallback version — patched at build time by build/build-dist.ps1 so the
# bundled Windows distribution reports the release version (the installer
# strips ledgrab-*.dist-info, so importlib.metadata fails there).
# In dev (running from source without `pip install -e .`) and on Android
# (Chaquopy embeds the source directly with no dist-info), we additionally
# read pyproject.toml so the version is always correct without manual sync.
_FALLBACK_VERSION = "0.4.2"
try:
__version__ = version("ledgrab")
except PackageNotFoundError:
__version__ = _FALLBACK_VERSION
def _read_pyproject_version() -> str | None:
"""Read version from pyproject.toml (server/pyproject.toml relative to this file).
Returns None if the file is absent (typical for installed/bundled distributions
where pyproject.toml isn't shipped) or unreadable.
"""
try:
# __init__.py -> ledgrab/ -> src/ -> server/
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
if not pyproject.is_file():
return None
try:
import tomllib # Python 3.11+
except ImportError:
return None
with pyproject.open("rb") as f:
data = tomllib.load(f)
v = data.get("project", {}).get("version")
return v if isinstance(v, str) else None
except Exception:
return None
# Prefer pyproject.toml when it sits next to the source (dev checkout). This
# avoids stale `pip install -e .` dist-info pinning an older version after a
# bump. When pyproject.toml isn't shipped (installed packages, Windows bundle,
# Android), fall back to importlib.metadata, then the patched literal.
_live = _read_pyproject_version()
if _live:
__version__ = _live
else:
try:
__version__ = version("ledgrab")
except PackageNotFoundError:
__version__ = _FALLBACK_VERSION
__author__ = "Alexei Dolgolyov"
__email__ = "dolgolyov.alexei@gmail.com"
+313 -150
View File
@@ -1,7 +1,7 @@
"""Output target routes: CRUD endpoints and batch state/metrics queries."""
import asyncio
from typing import Annotated
from typing import Annotated, Optional
from fastapi import APIRouter, Body, HTTPException, Depends
@@ -9,18 +9,27 @@ from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_device_store,
get_mqtt_store,
get_output_target_store,
get_processor_manager,
get_value_source_store,
)
from ledgrab.api.schemas.output_targets import (
HALightMappingSchema,
HALightOutputTargetCreate,
HALightOutputTargetResponse,
HALightOutputTargetUpdate,
LedOutputTargetCreate,
LedOutputTargetResponse,
LedOutputTargetUpdate,
OutputTargetCreate,
OutputTargetListResponse,
OutputTargetResponse,
OutputTargetUpdate,
Z2MLightMappingSchema,
Z2MLightOutputTargetCreate,
Z2MLightOutputTargetResponse,
Z2MLightOutputTargetUpdate,
)
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage import DeviceStore
@@ -30,6 +39,11 @@ from ledgrab.storage.ha_light_output_target import (
HALightMapping,
HALightOutputTarget,
)
from ledgrab.storage.z2m_light_output_target import (
Z2MLightMapping,
Z2MLightOutputTarget,
)
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.utils import get_logger
@@ -99,6 +113,42 @@ def _ha_light_target_to_response(
)
def _z2m_light_target_to_response(
target: Z2MLightOutputTarget,
) -> Z2MLightOutputTargetResponse:
"""Convert a Z2MLightOutputTarget to Z2MLightOutputTargetResponse."""
return Z2MLightOutputTargetResponse(
id=target.id,
name=target.name,
mqtt_source_id=target.mqtt_source_id or "",
source_kind=target.source_kind if target.source_kind in ("css", "color_vs") else "css",
color_strip_source_id=target.color_strip_source_id or "",
color_value_source_id=target.color_value_source_id or "",
brightness=target.brightness.to_dict(),
z2m_light_mappings=[
Z2MLightMappingSchema(
friendly_name=m.friendly_name,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale.to_dict(),
)
for m in target.light_mappings
],
base_topic=target.base_topic,
update_rate=target.update_rate.to_dict(),
transition=target.transition.to_dict(),
color_tolerance=target.color_tolerance.to_dict(),
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
stop_action=target.stop_action if target.stop_action in ("none", "turn_off") else "none",
description=target.description,
tags=target.tags,
icon=getattr(target, "icon", "") or "",
icon_color=getattr(target, "icon_color", "") or "",
created_at=target.created_at,
updated_at=target.updated_at,
)
def _validate_color_value_source(
value_source_store: ValueSourceStore, color_value_source_id: str
) -> None:
@@ -131,6 +181,8 @@ def _target_to_response(target) -> OutputTargetResponse:
return _led_target_to_response(target)
elif isinstance(target, HALightOutputTarget):
return _ha_light_target_to_response(target)
elif isinstance(target, Z2MLightOutputTarget):
return _z2m_light_target_to_response(target)
else:
# Fallback for unknown types — use LED response with defaults
return LedOutputTargetResponse(
@@ -146,6 +198,57 @@ def _target_to_response(target) -> OutputTargetResponse:
# ===== CRUD ENDPOINTS =====
def _build_ha_mappings(
payload: list[HALightMappingSchema] | None,
) -> list[HALightMapping] | None:
if not payload:
return None
return [
HALightMapping(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
)
for m in payload
]
def _build_z2m_mappings(
payload: list[Z2MLightMappingSchema] | None,
) -> list[Z2MLightMapping] | None:
if not payload:
return None
return [
Z2MLightMapping(
friendly_name=m.friendly_name,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
)
for m in payload
]
def _validate_device_exists(device_store: DeviceStore, device_id: str) -> None:
if not device_id:
return
try:
device_store.get_device(device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
def _validate_mqtt_source_exists(mqtt_store: MQTTSourceStore, mqtt_source_id: str) -> None:
"""Ensure the referenced MQTT source exists. Empty id is allowed (unconfigured)."""
if not mqtt_source_id:
return
try:
mqtt_store.get(mqtt_source_id)
except (ValueError, EntityNotFoundError):
raise HTTPException(status_code=422, detail=f"MQTT source {mqtt_source_id} not found")
@router.post(
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
)
@@ -156,65 +259,69 @@ async def create_target(
device_store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
value_source_store: ValueSourceStore = Depends(get_value_source_store),
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
):
"""Create a new output target."""
try:
# Validate device exists if provided
device_id = getattr(data, "device_id", "")
if device_id:
try:
device_store.get_device(device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
# Validate color VS reference for HA-light targets in color_vs mode
if (
getattr(data, "target_type", "") == "ha_light"
and getattr(data, "source_kind", "css") == "color_vs"
):
_validate_color_value_source(
value_source_store, getattr(data, "color_value_source_id", "")
)
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
ha_mappings = (
[
HALightMapping(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
match data:
case LedOutputTargetCreate():
_validate_device_exists(device_store, data.device_id)
target = target_store.create_wled_target(
name=data.name,
description=data.description,
tags=data.tags,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
brightness=data.brightness,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
)
for m in ha_light_mappings_raw
]
if ha_light_mappings_raw
else None
)
# Create in store
target = target_store.create_target(
name=data.name,
target_type=data.target_type,
device_id=device_id,
color_strip_source_id=getattr(data, "color_strip_source_id", ""),
brightness=getattr(data, "brightness", 1.0),
fps=getattr(data, "fps", 30),
keepalive_interval=getattr(data, "keepalive_interval", 1.0),
state_check_interval=getattr(data, "state_check_interval", 30),
min_brightness_threshold=getattr(data, "min_brightness_threshold", 0),
adaptive_fps=getattr(data, "adaptive_fps", False),
protocol=getattr(data, "protocol", "ddp"),
description=data.description,
tags=data.tags,
ha_source_id=getattr(data, "ha_source_id", ""),
source_kind=getattr(data, "source_kind", "css"),
color_value_source_id=getattr(data, "color_value_source_id", ""),
ha_light_mappings=ha_mappings,
update_rate=getattr(data, "update_rate", 2.0),
transition=getattr(data, "transition", 0.5),
color_tolerance=getattr(data, "color_tolerance", 5),
stop_action=getattr(data, "stop_action", "none"),
)
case HALightOutputTargetCreate():
if data.source_kind == "color_vs":
_validate_color_value_source(value_source_store, data.color_value_source_id)
target = target_store.create_ha_light_target(
name=data.name,
description=data.description,
tags=data.tags,
ha_source_id=data.ha_source_id,
source_kind=data.source_kind,
color_strip_source_id=data.color_strip_source_id,
color_value_source_id=data.color_value_source_id,
brightness=data.brightness,
ha_light_mappings=_build_ha_mappings(data.ha_light_mappings),
update_rate=data.update_rate,
transition=data.transition,
min_brightness_threshold=data.min_brightness_threshold,
color_tolerance=data.color_tolerance,
stop_action=data.stop_action,
)
case Z2MLightOutputTargetCreate():
if data.source_kind == "color_vs":
_validate_color_value_source(value_source_store, data.color_value_source_id)
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
target = target_store.create_z2m_light_target(
name=data.name,
description=data.description,
tags=data.tags,
mqtt_source_id=data.mqtt_source_id,
source_kind=data.source_kind,
color_strip_source_id=data.color_strip_source_id,
color_value_source_id=data.color_value_source_id,
brightness=data.brightness,
z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings),
base_topic=data.base_topic,
update_rate=data.update_rate,
transition=data.transition,
min_brightness_threshold=data.min_brightness_threshold,
color_tolerance=data.color_tolerance,
stop_action=data.stop_action,
)
case _: # pragma: no cover — Pydantic discriminator already ensures one of the three
raise HTTPException(status_code=400, detail="Unknown target_type")
# Register in processor manager
try:
@@ -282,6 +389,18 @@ async def get_target(
raise HTTPException(status_code=404, detail=str(e))
def _resolve_effective_color_vs_id(
target_store: OutputTargetStore, target_id: str, payload_id: Optional[str]
) -> str:
if payload_id is not None:
return payload_id
try:
existing = target_store.get_target(target_id)
except ValueError:
return ""
return getattr(existing, "color_value_source_id", "") or ""
@router.put(
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
)
@@ -293,116 +412,160 @@ async def update_target(
device_store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
value_source_store: ValueSourceStore = Depends(get_value_source_store),
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
):
"""Update a output target."""
try:
# Validate device exists if changing
device_id = getattr(data, "device_id", None)
if device_id is not None and device_id:
try:
device_store.get_device(device_id)
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
css_changed = False
brightness_changed = False
settings_changed = False
device_changed = False
# Validate color VS reference for HA-light targets switching into / staying in color_vs
if getattr(data, "target_type", "") == "ha_light":
new_kind = getattr(data, "source_kind", None)
new_color_vs = getattr(data, "color_value_source_id", None)
if new_kind == "color_vs" or (new_kind is None and new_color_vs):
# Determine effective id: payload id if provided, else existing target's id
effective_id = new_color_vs
if effective_id is None:
try:
existing = target_store.get_target(target_id)
effective_id = getattr(existing, "color_value_source_id", "")
except ValueError:
effective_id = ""
_validate_color_value_source(value_source_store, effective_id or "")
# Build HA light mappings if provided
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
ha_mappings = None
if ha_light_mappings_raw is not None:
ha_mappings = [
HALightMapping(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
match data:
case LedOutputTargetUpdate():
if data.device_id:
_validate_device_exists(device_store, data.device_id)
target = target_store.update_wled_target(
target_id,
name=data.name,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
brightness=data.brightness,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
)
for m in ha_light_mappings_raw
]
# Update in store
target = target_store.update_target(
target_id=target_id,
name=data.name,
device_id=device_id,
color_strip_source_id=getattr(data, "color_strip_source_id", None),
brightness=getattr(data, "brightness", None),
fps=getattr(data, "fps", None),
keepalive_interval=getattr(data, "keepalive_interval", None),
state_check_interval=getattr(data, "state_check_interval", None),
min_brightness_threshold=getattr(data, "min_brightness_threshold", None),
adaptive_fps=getattr(data, "adaptive_fps", None),
protocol=getattr(data, "protocol", None),
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
ha_source_id=getattr(data, "ha_source_id", None),
source_kind=getattr(data, "source_kind", None),
color_value_source_id=getattr(data, "color_value_source_id", None),
ha_light_mappings=ha_mappings,
update_rate=getattr(data, "update_rate", None),
transition=getattr(data, "transition", None),
color_tolerance=getattr(data, "color_tolerance", None),
stop_action=getattr(data, "stop_action", None),
)
css_changed = data.color_strip_source_id is not None
brightness_changed = data.brightness is not None
settings_changed = any(
v is not None
for v in (
data.fps,
data.keepalive_interval,
data.state_check_interval,
data.min_brightness_threshold,
data.adaptive_fps,
data.brightness,
)
)
device_changed = data.device_id is not None
case HALightOutputTargetUpdate():
# Validate color VS when switching into / staying in color_vs mode
if data.source_kind == "color_vs" or (
data.source_kind is None and data.color_value_source_id
):
effective_id = _resolve_effective_color_vs_id(
target_store, target_id, data.color_value_source_id
)
_validate_color_value_source(value_source_store, effective_id)
target = target_store.update_ha_light_target(
target_id,
name=data.name,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
ha_source_id=data.ha_source_id,
source_kind=data.source_kind,
color_strip_source_id=data.color_strip_source_id,
color_value_source_id=data.color_value_source_id,
brightness=data.brightness,
ha_light_mappings=_build_ha_mappings(data.ha_light_mappings),
update_rate=data.update_rate,
transition=data.transition,
min_brightness_threshold=data.min_brightness_threshold,
color_tolerance=data.color_tolerance,
stop_action=data.stop_action,
)
css_changed = data.color_strip_source_id is not None
brightness_changed = data.brightness is not None
settings_changed = any(
v is not None
for v in (
data.source_kind,
data.color_value_source_id,
data.brightness,
data.update_rate,
data.transition,
data.min_brightness_threshold,
data.color_tolerance,
data.ha_light_mappings,
data.stop_action,
)
)
case Z2MLightOutputTargetUpdate():
if data.source_kind == "color_vs" or (
data.source_kind is None and data.color_value_source_id
):
effective_id = _resolve_effective_color_vs_id(
target_store, target_id, data.color_value_source_id
)
_validate_color_value_source(value_source_store, effective_id)
if data.mqtt_source_id:
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
target = target_store.update_z2m_light_target(
target_id,
name=data.name,
description=data.description,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
mqtt_source_id=data.mqtt_source_id,
source_kind=data.source_kind,
color_strip_source_id=data.color_strip_source_id,
color_value_source_id=data.color_value_source_id,
brightness=data.brightness,
z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings),
base_topic=data.base_topic,
update_rate=data.update_rate,
transition=data.transition,
min_brightness_threshold=data.min_brightness_threshold,
color_tolerance=data.color_tolerance,
stop_action=data.stop_action,
)
css_changed = data.color_strip_source_id is not None
brightness_changed = data.brightness is not None
settings_changed = any(
v is not None
for v in (
data.source_kind,
data.color_value_source_id,
data.mqtt_source_id,
data.brightness,
data.base_topic,
data.update_rate,
data.transition,
data.min_brightness_threshold,
data.color_tolerance,
data.z2m_light_mappings,
data.stop_action,
)
)
case _: # pragma: no cover — Pydantic discriminator already ensures one of the three
raise HTTPException(status_code=400, detail="Unknown target_type")
# Sync processor manager (run in thread — css release/acquire can block)
color_strip_source_id = getattr(data, "color_strip_source_id", None)
fps = getattr(data, "fps", None)
keepalive_interval = getattr(data, "keepalive_interval", None)
state_check_interval = getattr(data, "state_check_interval", None)
min_brightness_threshold = getattr(data, "min_brightness_threshold", None)
adaptive_fps = getattr(data, "adaptive_fps", None)
update_rate = getattr(data, "update_rate", None)
transition = getattr(data, "transition", None)
color_tolerance = getattr(data, "color_tolerance", None)
brightness = getattr(data, "brightness", None)
stop_action = getattr(data, "stop_action", None)
source_kind = getattr(data, "source_kind", None)
color_value_source_id = getattr(data, "color_value_source_id", None)
try:
await asyncio.to_thread(
target.sync_with_manager,
manager,
settings_changed=(
fps is not None
or keepalive_interval is not None
or state_check_interval is not None
or min_brightness_threshold is not None
or adaptive_fps is not None
or update_rate is not None
or transition is not None
or color_tolerance is not None
or ha_light_mappings_raw is not None
or brightness is not None
or stop_action is not None
or source_kind is not None
or color_value_source_id is not None
),
css_changed=color_strip_source_id is not None,
brightness_changed=brightness is not None,
settings_changed=settings_changed,
css_changed=css_changed,
brightness_changed=brightness_changed,
)
except ValueError as e:
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
pass
# Device change requires async stop -> swap -> start cycle
if device_id is not None:
# LED-only: device change requires async stop -> swap -> start cycle
if device_changed and isinstance(target, WledOutputTarget):
try:
await manager.update_target_device(target_id, target.device_id)
except ValueError as e:
@@ -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 =====
+10 -11
View File
@@ -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"),
]
+8 -47
View File
@@ -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:
runtime = self._mqtt_manager.get_runtime(rule.mqtt_source_id)
if runtime and runtime.is_connected:
value = runtime.get_last_value(rule.topic)
elif self._mqtt_manager is not None:
# No source specified — try first available runtime
runtime = self._mqtt_manager.get_first_runtime()
if runtime:
value = runtime.get_last_value(rule.topic)
# Fallback to legacy global service
if value is None and self._mqtt_service is not None and self._mqtt_service.is_connected:
value = self._mqtt_service.get_last_value(rule.topic)
# Multi-broker model: the rule references a specific MQTTSource.
# Rules without one are no-ops (UI should enforce a source on save).
if self._mqtt_manager is None or not rule.mqtt_source_id:
return False
runtime = self._mqtt_manager.get_runtime(rule.mqtt_source_id)
if runtime is None or not runtime.is_connected:
return False
value = runtime.get_last_value(rule.topic)
if value is None:
return False
matchers = {
@@ -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
+58 -31
View File
@@ -1,7 +1,12 @@
"""MQTT LED client — publishes pixel data to an MQTT topic."""
"""MQTT LED client — publishes pixel data to an MQTT topic on a configured broker.
The client acquires a per-source runtime from :class:`MQTTManager` at
``connect()`` time and releases it at ``close()``. Every device references
an ``mqtt_source_id`` so different devices can talk to different brokers.
"""
import json
from typing import List, Tuple, Union
from typing import List, Optional, Tuple, Union
import numpy as np
@@ -10,23 +15,11 @@ from ledgrab.utils import get_logger
logger = get_logger(__name__)
# Singleton reference — injected from main.py at startup
_mqtt_service = None
def set_mqtt_service(service) -> None:
global _mqtt_service
_mqtt_service = service
def get_mqtt_service():
return _mqtt_service
def parse_mqtt_url(url: str) -> str:
"""Extract topic from an mqtt:// URL.
Format: mqtt://topic/path (broker connection is global via config)
Format: mqtt://topic/path (broker connection is per-source, not in URL)
"""
if url.startswith("mqtt://"):
return url[7:]
@@ -34,36 +27,64 @@ def parse_mqtt_url(url: str) -> str:
class MQTTLEDClient(LEDClient):
"""Publishes JSON pixel data to an MQTT topic via the shared service."""
"""Publishes JSON pixel data to an MQTT topic via an MQTTManager runtime."""
def __init__(self, url: str, led_count: int = 0, **kwargs):
def __init__(
self,
url: str,
led_count: int = 0,
*,
mqtt_manager=None,
mqtt_source_id: str = "",
**kwargs,
):
self._topic = parse_mqtt_url(url)
self._led_count = led_count
self._mqtt_manager = mqtt_manager
self._mqtt_source_id = mqtt_source_id
self._runtime = None
self._connected = False
async def connect(self) -> bool:
svc = _mqtt_service
if svc is None or not svc.is_enabled:
raise ConnectionError("MQTT service not available")
if not svc.is_connected:
raise ConnectionError("MQTT service not connected to broker")
if self._mqtt_manager is None:
raise ConnectionError("MQTT manager not available")
if not self._mqtt_source_id:
raise ConnectionError("Device has no mqtt_source_id configured")
try:
self._runtime = await self._mqtt_manager.acquire(self._mqtt_source_id)
except Exception as e:
raise ConnectionError(f"Failed to acquire MQTT runtime: {e}") from e
if not self._runtime.is_connected:
# Runtime exists but the broker hasn't established the TCP
# connection yet — leave the LED client in a "queued" state so
# publishes get buffered (MQTTRuntime.publish queues on
# disconnect). The runtime will drain when connection is made.
logger.info(
"MQTT LED client %s: runtime acquired but broker not yet connected",
self._mqtt_source_id,
)
self._connected = True
return True
async def close(self) -> None:
if self._runtime is not None and self._mqtt_manager is not None:
try:
await self._mqtt_manager.release(self._mqtt_source_id)
except Exception as e:
logger.warning("Failed to release MQTT runtime %s: %s", self._mqtt_source_id, e)
self._runtime = None
self._connected = False
@property
def is_connected(self) -> bool:
return self._connected and _mqtt_service is not None and _mqtt_service.is_connected
return self._connected and self._runtime is not None and self._runtime.is_connected
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
svc = _mqtt_service
if svc is None or not svc.is_connected:
if self._runtime is None:
return False
if isinstance(pixels, np.ndarray):
@@ -79,7 +100,7 @@ class MQTTLEDClient(LEDClient):
}
)
await svc.publish(self._topic, payload, retain=False, qos=0)
await self._runtime.publish(self._topic, payload, retain=False, qos=0)
return True
@classmethod
@@ -88,16 +109,22 @@ class MQTTLEDClient(LEDClient):
url: str,
http_client,
prev_health=None,
*,
mqtt_manager=None,
mqtt_source_id: Optional[str] = None,
) -> DeviceHealth:
from datetime import datetime, timezone
svc = _mqtt_service
if svc is None or not svc.is_enabled:
if mqtt_manager is None or not mqtt_source_id:
return DeviceHealth(
online=False, error="MQTT disabled", last_checked=datetime.now(timezone.utc)
online=False,
error="MQTT source not configured",
last_checked=datetime.now(timezone.utc),
)
runtime = mqtt_manager.get_runtime(mqtt_source_id)
connected = bool(runtime and runtime.is_connected)
return DeviceHealth(
online=svc.is_connected,
online=connected,
last_checked=datetime.now(timezone.utc),
error=None if svc.is_connected else "MQTT broker disconnected",
error=None if connected else "MQTT broker disconnected",
)
@@ -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
View File
@@ -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()
+184 -7
View File
@@ -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) ──────────────────────── */
+387 -102
View File
@@ -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) => ({
icon: _icon(P.home),
text: `${c.entity_id || ''} = ${c.state || '*'}`,
title: t('automations.rule.home_assistant'),
}),
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: `${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[] = [
{
icon: ICON_EDIT,
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('common.edit'),
dataAttrs: { 'data-action': 'edit' },
},
];
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;
+27 -2
View File
@@ -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;
+100 -1
View File
@@ -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.510 Hz). Zigbee mesh tolerates ~510 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:",
+75 -1
View File
@@ -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": "Обычное",
+72 -1
View File
@@ -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": "正常",
+12 -6
View File
@@ -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
+10 -1
View File
@@ -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:
+35 -13
View File
@@ -2,16 +2,31 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Optional
from typing import ClassVar, Dict, List, Optional, Type
@dataclass
class OutputTarget:
"""Base class for output targets."""
"""Base class for output targets.
Subclasses register themselves into ``_registry`` via the
``type_key="..."`` class-header argument; :meth:`from_dict`
uses that registry to dispatch deserialization without a
per-type if/elif chain. The :attr:`target_type` instance
field stays as the persisted discriminator.
"""
# Discriminator-keyed registry of concrete subclasses. Populated
# automatically via ``__init_subclass__``. Requires the subclass
# modules to be imported at least once before ``from_dict`` runs;
# ``output_target_store`` imports all three at module load, which
# is sufficient for the live app and tests.
_registry: ClassVar[Dict[str, Type["OutputTarget"]]] = {}
_type_key: ClassVar[str] = ""
id: str
name: str
target_type: str # "led", "ha_light"
target_type: str # "led", "ha_light", "z2m_light"
created_at: datetime
updated_at: datetime
description: Optional[str] = None
@@ -22,6 +37,12 @@ class OutputTarget:
icon: str = ""
icon_color: str = ""
def __init_subclass__(cls, *, type_key: str = "", **kwargs) -> None:
super().__init_subclass__(**kwargs)
if type_key:
cls._type_key = type_key
OutputTarget._registry[type_key] = cls
def register_with_manager(self, manager) -> None:
"""Register this target with the processor manager. Subclasses override."""
pass
@@ -74,14 +95,15 @@ class OutputTarget:
@classmethod
def from_dict(cls, data: dict) -> "OutputTarget":
"""Create from dictionary, dispatching to the correct subclass."""
"""Create from dictionary, dispatching to the correct subclass via the registry."""
target_type = data.get("target_type", "led")
if target_type == "led":
from ledgrab.storage.wled_output_target import WledOutputTarget
return WledOutputTarget.from_dict(data)
if target_type == "ha_light":
from ledgrab.storage.ha_light_output_target import HALightOutputTarget
return HALightOutputTarget.from_dict(data)
raise ValueError(f"Unknown target type: {target_type}")
# When called on a concrete subclass (e.g. ``WledOutputTarget.from_dict``),
# respect that — subclasses override this method to actually build themselves.
if cls is not OutputTarget:
raise NotImplementedError(
f"{cls.__name__}.from_dict must be implemented on the subclass"
)
impl = OutputTarget._registry.get(target_type)
if impl is None:
raise ValueError(f"Unknown target type: {target_type}")
return impl.from_dict(data)
+454 -112
View File
@@ -2,7 +2,7 @@
import uuid
from datetime import datetime, timezone
from typing import List, Optional
from typing import Any, List, Optional
from ledgrab.storage.base_sqlite_store import BaseSqliteStore
from ledgrab.storage.bindable import BindableFloat
@@ -13,12 +13,21 @@ from ledgrab.storage.ha_light_output_target import (
HALightMapping,
HALightOutputTarget,
)
from ledgrab.storage.z2m_light_output_target import (
DEFAULT_Z2M_BASE_TOPIC,
Z2MLightMapping,
Z2MLightOutputTarget,
)
from ledgrab.utils import get_logger
logger = get_logger(__name__)
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds
_VALID_HA_SOURCE_KINDS = ("css", "color_vs")
_VALID_HA_STOP_ACTIONS = ("none", "turn_off", "restore")
_VALID_Z2M_STOP_ACTIONS = ("none", "turn_off")
class OutputTargetStore(BaseSqliteStore[OutputTarget]):
"""Persistent storage for output targets."""
@@ -34,6 +43,177 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
get_target = BaseSqliteStore.get
delete_target = BaseSqliteStore.delete
# ---- shared helpers -------------------------------------------------
def _check_unique_name(self, name: str) -> None:
for target in self._items.values():
if target.name == name:
raise ValueError(f"Output target with name '{name}' already exists")
def _new_id_and_now(self) -> tuple[str, datetime]:
return f"pt_{uuid.uuid4().hex[:8]}", datetime.now(timezone.utc)
@staticmethod
def _resolve_brightness(brightness: Any, brightness_value_source_id: str = "") -> BindableFloat:
if isinstance(brightness, BindableFloat):
return brightness
if brightness is not None:
return BindableFloat.from_raw(brightness, default=1.0)
if brightness_value_source_id:
return BindableFloat(1.0, source_id=brightness_value_source_id)
return BindableFloat(1.0)
@staticmethod
def _resolve_transition(transition: Any, default: float) -> BindableFloat:
if isinstance(transition, BindableFloat):
return transition
if transition is not None:
return BindableFloat.from_raw(transition, default=default)
return BindableFloat(default)
def _finalize(
self, target: OutputTarget, *, tags: Optional[List[str]], log_type: str
) -> OutputTarget:
target.tags = tags or []
self._items[target.id] = target
self._save_item(target.id, target)
logger.info(f"Created output target: {target.name} ({target.id}, type={log_type})")
return target
# ---- typed factory methods -----------------------------------------
def create_wled_target(
self,
name: str,
*,
device_id: str = "",
color_strip_source_id: str = "",
brightness: Any = None,
fps: Any = 30,
keepalive_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
min_brightness_threshold: Any = 0,
adaptive_fps: bool = False,
protocol: str = "ddp",
description: Optional[str] = None,
tags: Optional[List[str]] = None,
# legacy compat
brightness_value_source_id: str = "",
) -> WledOutputTarget:
"""Create a WLED/LED output target."""
self._check_unique_name(name)
target_id, now = self._new_id_and_now()
target = WledOutputTarget(
id=target_id,
name=name,
target_type="led",
device_id=device_id,
color_strip_source_id=color_strip_source_id,
brightness=self._resolve_brightness(brightness, brightness_value_source_id),
fps=BindableFloat.from_raw(fps, default=30.0),
keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval,
min_brightness_threshold=BindableFloat.from_raw(min_brightness_threshold, default=0.0),
adaptive_fps=adaptive_fps,
protocol=protocol,
description=description,
created_at=now,
updated_at=now,
)
return self._finalize(target, tags=tags, log_type="led")
def create_ha_light_target(
self,
name: str,
*,
ha_source_id: str = "",
source_kind: str = "css",
color_strip_source_id: str = "",
color_value_source_id: str = "",
brightness: Any = None,
ha_light_mappings: Optional[List[HALightMapping]] = None,
update_rate: Any = 2.0,
transition: Any = None,
min_brightness_threshold: Any = 0,
color_tolerance: Any = 5,
stop_action: str = "none",
description: Optional[str] = None,
tags: Optional[List[str]] = None,
# legacy compat
brightness_value_source_id: str = "",
) -> HALightOutputTarget:
"""Create a Home Assistant light output target."""
self._check_unique_name(name)
target_id, now = self._new_id_and_now()
target = HALightOutputTarget(
id=target_id,
name=name,
target_type="ha_light",
ha_source_id=ha_source_id,
source_kind=source_kind if source_kind in _VALID_HA_SOURCE_KINDS else "css",
color_strip_source_id=color_strip_source_id,
color_value_source_id=color_value_source_id,
brightness=self._resolve_brightness(brightness, brightness_value_source_id),
light_mappings=ha_light_mappings or [],
update_rate=BindableFloat.from_raw(update_rate, default=2.0),
transition=self._resolve_transition(transition, default=0.5),
min_brightness_threshold=BindableFloat.from_raw(min_brightness_threshold, default=0.0),
color_tolerance=BindableFloat.from_raw(color_tolerance, default=5.0),
stop_action=stop_action if stop_action in _VALID_HA_STOP_ACTIONS else "none",
description=description,
created_at=now,
updated_at=now,
)
return self._finalize(target, tags=tags, log_type="ha_light")
def create_z2m_light_target(
self,
name: str,
*,
mqtt_source_id: str = "",
source_kind: str = "css",
color_strip_source_id: str = "",
color_value_source_id: str = "",
brightness: Any = None,
z2m_light_mappings: Optional[List[Z2MLightMapping]] = None,
base_topic: str = DEFAULT_Z2M_BASE_TOPIC,
update_rate: Any = 5.0,
transition: Any = None,
min_brightness_threshold: Any = 0,
color_tolerance: Any = 5,
stop_action: str = "none",
description: Optional[str] = None,
tags: Optional[List[str]] = None,
# legacy compat
brightness_value_source_id: str = "",
) -> Z2MLightOutputTarget:
"""Create a Zigbee2MQTT light output target."""
self._check_unique_name(name)
target_id, now = self._new_id_and_now()
target = Z2MLightOutputTarget(
id=target_id,
name=name,
target_type="z2m_light",
source_kind=source_kind if source_kind in _VALID_HA_SOURCE_KINDS else "css",
color_strip_source_id=color_strip_source_id,
color_value_source_id=color_value_source_id,
brightness=self._resolve_brightness(brightness, brightness_value_source_id),
light_mappings=z2m_light_mappings or [],
mqtt_source_id=mqtt_source_id,
base_topic=(base_topic or "").strip() or DEFAULT_Z2M_BASE_TOPIC,
update_rate=BindableFloat.from_raw(update_rate, default=5.0),
transition=self._resolve_transition(transition, default=0.3),
min_brightness_threshold=BindableFloat.from_raw(min_brightness_threshold, default=0.0),
color_tolerance=BindableFloat.from_raw(color_tolerance, default=5.0),
stop_action=stop_action if stop_action in _VALID_Z2M_STOP_ACTIONS else "none",
description=description,
created_at=now,
updated_at=now,
)
return self._finalize(target, tags=tags, log_type="z2m_light")
# ---- legacy generic shim (deferred deletion in Phase 5) ------------
def create_target(
self,
name: str,
@@ -53,101 +233,232 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
source_kind: str = "css",
color_value_source_id: str = "",
ha_light_mappings: Optional[List[HALightMapping]] = None,
update_rate: float = 2.0,
update_rate: Optional[float] = None,
transition=None,
color_tolerance: int = 5,
stop_action: str = "none",
# legacy compat
z2m_light_mappings: Optional[List[Z2MLightMapping]] = None,
base_topic: str = DEFAULT_Z2M_BASE_TOPIC,
mqtt_source_id: str = "",
brightness_value_source_id: str = "",
) -> OutputTarget:
"""Create a new output target.
"""Generic create that dispatches to the typed factory by ``target_type``.
Raises:
ValueError: If validation fails
Kept as a thin shim during the refactor new code should call
:meth:`create_wled_target`, :meth:`create_ha_light_target`, or
:meth:`create_z2m_light_target` directly.
"""
if target_type not in ("led", "ha_light"):
raise ValueError(f"Invalid target type: {target_type}")
# Check for duplicate name
for target in self._items.values():
if target.name == name:
raise ValueError(f"Output target with name '{name}' already exists")
target_id = f"pt_{uuid.uuid4().hex[:8]}"
now = datetime.now(timezone.utc)
# Resolve brightness to BindableFloat
if isinstance(brightness, BindableFloat):
bright = brightness
elif brightness is not None:
bright = BindableFloat.from_raw(brightness, default=1.0)
elif brightness_value_source_id:
bright = BindableFloat(1.0, source_id=brightness_value_source_id)
else:
bright = BindableFloat(1.0)
if target_type == "led":
target: OutputTarget = WledOutputTarget(
id=target_id,
return self.create_wled_target(
name=name,
target_type="led",
device_id=device_id,
color_strip_source_id=color_strip_source_id,
brightness=bright,
fps=BindableFloat.from_raw(fps, default=30.0),
brightness=brightness,
fps=fps,
keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval,
min_brightness_threshold=BindableFloat.from_raw(
min_brightness_threshold, default=0.0
),
min_brightness_threshold=min_brightness_threshold,
adaptive_fps=adaptive_fps,
protocol=protocol,
description=description,
created_at=now,
updated_at=now,
tags=tags,
brightness_value_source_id=brightness_value_source_id,
)
elif target_type == "ha_light":
# Resolve transition
if isinstance(transition, BindableFloat):
trans = transition
elif transition is not None:
trans = BindableFloat.from_raw(transition, default=0.5)
else:
trans = BindableFloat(0.5)
target = HALightOutputTarget(
id=target_id,
if target_type == "ha_light":
return self.create_ha_light_target(
name=name,
target_type="ha_light",
ha_source_id=ha_source_id,
source_kind=source_kind if source_kind in ("css", "color_vs") else "css",
source_kind=source_kind,
color_strip_source_id=color_strip_source_id,
color_value_source_id=color_value_source_id,
brightness=bright,
light_mappings=ha_light_mappings or [],
update_rate=BindableFloat.from_raw(update_rate, default=2.0),
transition=trans,
min_brightness_threshold=BindableFloat.from_raw(
min_brightness_threshold, default=0.0
),
color_tolerance=BindableFloat.from_raw(color_tolerance, default=5.0),
stop_action=(
stop_action if stop_action in ("none", "turn_off", "restore") else "none"
),
brightness=brightness,
ha_light_mappings=ha_light_mappings,
update_rate=update_rate if update_rate is not None else 2.0,
transition=transition,
min_brightness_threshold=min_brightness_threshold,
color_tolerance=color_tolerance,
stop_action=stop_action,
description=description,
created_at=now,
updated_at=now,
tags=tags,
brightness_value_source_id=brightness_value_source_id,
)
else:
raise ValueError(f"Unknown target type: {target_type}")
if target_type == "z2m_light":
return self.create_z2m_light_target(
name=name,
mqtt_source_id=mqtt_source_id,
source_kind=source_kind,
color_strip_source_id=color_strip_source_id,
color_value_source_id=color_value_source_id,
brightness=brightness,
z2m_light_mappings=z2m_light_mappings,
base_topic=base_topic,
update_rate=update_rate if update_rate is not None else 5.0,
transition=transition,
min_brightness_threshold=min_brightness_threshold,
color_tolerance=color_tolerance,
stop_action=stop_action,
description=description,
tags=tags,
brightness_value_source_id=brightness_value_source_id,
)
raise ValueError(f"Invalid target type: {target_type}")
target.tags = tags or []
self._items[target_id] = target
self._save_item(target_id, target)
# ---- typed update methods ------------------------------------------
logger.info(f"Created output target: {name} ({target_id}, type={target_type})")
def _begin_update(self, target_id: str, new_name: Optional[str]) -> OutputTarget:
if target_id not in self._items:
raise ValueError(f"Output target not found: {target_id}")
target = self._items[target_id]
if new_name is not None:
for other in self._items.values():
if other.id != target_id and other.name == new_name:
raise ValueError(f"Output target with name '{new_name}' already exists")
return target
def _commit_update(self, target: OutputTarget) -> OutputTarget:
target.updated_at = datetime.now(timezone.utc)
self._save_item(target.id, target)
logger.info(f"Updated output target: {target.id}")
return target
def update_wled_target(
self,
target_id: str,
*,
name: Optional[str] = None,
device_id: Optional[str] = None,
color_strip_source_id: Optional[str] = None,
brightness: Any = None,
fps: Any = None,
keepalive_interval: Optional[float] = None,
state_check_interval: Optional[int] = None,
min_brightness_threshold: Any = None,
adaptive_fps: Optional[bool] = None,
protocol: Optional[str] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
brightness_value_source_id: Optional[str] = None,
) -> WledOutputTarget:
target = self._begin_update(target_id, name)
if not isinstance(target, WledOutputTarget):
raise ValueError(f"Target {target_id} is not a WLED target")
target.update_fields(
name=name,
device_id=device_id,
color_strip_source_id=color_strip_source_id,
brightness=brightness,
brightness_value_source_id=brightness_value_source_id,
fps=fps,
keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval,
min_brightness_threshold=min_brightness_threshold,
adaptive_fps=adaptive_fps,
protocol=protocol,
description=description,
tags=tags,
icon=icon,
icon_color=icon_color,
)
return self._commit_update(target) # type: ignore[return-value]
def update_ha_light_target(
self,
target_id: str,
*,
name: Optional[str] = None,
ha_source_id: Optional[str] = None,
source_kind: Optional[str] = None,
color_strip_source_id: Optional[str] = None,
color_value_source_id: Optional[str] = None,
brightness: Any = None,
ha_light_mappings: Optional[List[HALightMapping]] = None,
update_rate: Any = None,
transition: Any = None,
min_brightness_threshold: Any = None,
color_tolerance: Any = None,
stop_action: Optional[str] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
brightness_value_source_id: Optional[str] = None,
) -> HALightOutputTarget:
target = self._begin_update(target_id, name)
if not isinstance(target, HALightOutputTarget):
raise ValueError(f"Target {target_id} is not an HA-light target")
target.update_fields(
name=name,
ha_source_id=ha_source_id,
source_kind=source_kind,
color_strip_source_id=color_strip_source_id,
color_value_source_id=color_value_source_id,
brightness=brightness,
brightness_value_source_id=brightness_value_source_id,
light_mappings=ha_light_mappings,
update_rate=update_rate,
transition=transition,
min_brightness_threshold=min_brightness_threshold,
color_tolerance=color_tolerance,
stop_action=stop_action,
description=description,
tags=tags,
icon=icon,
icon_color=icon_color,
)
return self._commit_update(target) # type: ignore[return-value]
def update_z2m_light_target(
self,
target_id: str,
*,
name: Optional[str] = None,
mqtt_source_id: Optional[str] = None,
source_kind: Optional[str] = None,
color_strip_source_id: Optional[str] = None,
color_value_source_id: Optional[str] = None,
brightness: Any = None,
z2m_light_mappings: Optional[List[Z2MLightMapping]] = None,
base_topic: Optional[str] = None,
update_rate: Any = None,
transition: Any = None,
min_brightness_threshold: Any = None,
color_tolerance: Any = None,
stop_action: Optional[str] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
icon: Optional[str] = None,
icon_color: Optional[str] = None,
brightness_value_source_id: Optional[str] = None,
) -> Z2MLightOutputTarget:
target = self._begin_update(target_id, name)
if not isinstance(target, Z2MLightOutputTarget):
raise ValueError(f"Target {target_id} is not a Z2M-light target")
target.update_fields(
name=name,
source_kind=source_kind,
color_strip_source_id=color_strip_source_id,
color_value_source_id=color_value_source_id,
brightness=brightness,
brightness_value_source_id=brightness_value_source_id,
light_mappings=z2m_light_mappings,
mqtt_source_id=mqtt_source_id,
base_topic=base_topic,
update_rate=update_rate,
transition=transition,
min_brightness_threshold=min_brightness_threshold,
color_tolerance=color_tolerance,
stop_action=stop_action,
description=description,
tags=tags,
icon=icon,
icon_color=icon_color,
)
return self._commit_update(target) # type: ignore[return-value]
# ---- legacy generic shim (deferred deletion in Phase 5) ------------
def update_target(
self,
target_id: str,
@@ -173,56 +484,83 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
transition=None,
color_tolerance=None,
stop_action=None,
# legacy compat
z2m_light_mappings=None,
base_topic=None,
mqtt_source_id=None,
brightness_value_source_id=None,
) -> OutputTarget:
"""Update an output target.
"""Generic update that dispatches by the target's existing type.
Raises:
ValueError: If target not found or validation fails
Kept as a thin shim during the refactor new code should call
:meth:`update_wled_target`, :meth:`update_ha_light_target`, or
:meth:`update_z2m_light_target` directly.
"""
if target_id not in self._items:
raise ValueError(f"Output target not found: {target_id}")
target = self._items[target_id]
if name is not None:
# Check for duplicate name (exclude self)
for other in self._items.values():
if other.id != target_id and other.name == name:
raise ValueError(f"Output target with name '{name}' already exists")
target.update_fields(
name=name,
device_id=device_id,
color_strip_source_id=color_strip_source_id,
brightness=brightness,
brightness_value_source_id=brightness_value_source_id,
fps=fps,
keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval,
min_brightness_threshold=min_brightness_threshold,
adaptive_fps=adaptive_fps,
protocol=protocol,
description=description,
tags=tags,
icon=icon,
icon_color=icon_color,
ha_source_id=ha_source_id,
source_kind=source_kind,
color_value_source_id=color_value_source_id,
light_mappings=ha_light_mappings,
update_rate=update_rate,
transition=transition,
color_tolerance=color_tolerance,
stop_action=stop_action,
)
target.updated_at = datetime.now(timezone.utc)
self._save_item(target_id, target)
logger.info(f"Updated output target: {target_id}")
return target
if isinstance(target, WledOutputTarget):
return self.update_wled_target(
target_id,
name=name,
device_id=device_id,
color_strip_source_id=color_strip_source_id,
brightness=brightness,
fps=fps,
keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval,
min_brightness_threshold=min_brightness_threshold,
adaptive_fps=adaptive_fps,
protocol=protocol,
description=description,
tags=tags,
icon=icon,
icon_color=icon_color,
brightness_value_source_id=brightness_value_source_id,
)
if isinstance(target, HALightOutputTarget):
return self.update_ha_light_target(
target_id,
name=name,
ha_source_id=ha_source_id,
source_kind=source_kind,
color_strip_source_id=color_strip_source_id,
color_value_source_id=color_value_source_id,
brightness=brightness,
ha_light_mappings=ha_light_mappings,
update_rate=update_rate,
transition=transition,
min_brightness_threshold=min_brightness_threshold,
color_tolerance=color_tolerance,
stop_action=stop_action,
description=description,
tags=tags,
icon=icon,
icon_color=icon_color,
brightness_value_source_id=brightness_value_source_id,
)
if isinstance(target, Z2MLightOutputTarget):
return self.update_z2m_light_target(
target_id,
name=name,
mqtt_source_id=mqtt_source_id,
source_kind=source_kind,
color_strip_source_id=color_strip_source_id,
color_value_source_id=color_value_source_id,
brightness=brightness,
z2m_light_mappings=z2m_light_mappings,
base_topic=base_topic,
update_rate=update_rate,
transition=transition,
min_brightness_threshold=min_brightness_threshold,
color_tolerance=color_tolerance,
stop_action=stop_action,
description=description,
tags=tags,
icon=icon,
icon_color=icon_color,
brightness_value_source_id=brightness_value_source_id,
)
raise ValueError(f"Unknown target class: {type(target).__name__}")
def get_targets_for_device(self, device_id: str) -> List[OutputTarget]:
"""Get all targets that reference a specific device."""
@@ -240,6 +578,10 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
result.append(target.name)
elif isinstance(target, HALightOutputTarget) and target.color_strip_source_id == css_id:
result.append(target.name)
elif (
isinstance(target, Z2MLightOutputTarget) and target.color_strip_source_id == css_id
):
result.append(target.name)
return result
def count(self) -> int:
@@ -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())
),
)
+1
View File
@@ -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">&#x2715;</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.510 Hz). Zigbee mesh tolerates ~510 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 (01). 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">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveZ2MLightEditor()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">&#x2713;</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()
+76
View File
@@ -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
-4
View File
@@ -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