fix: HA light target — brightness source, transition=0, dashboard type label
Lint & Test / test (push) Successful in 1m13s

- Add brightness_value_source_id to HALightOutputTarget model, to_dict,
  from_dict, update_fields, register_with_manager, API response
- Wire value stream in HALightTargetProcessor: acquire/release on
  start/stop, multiply brightness in _update_lights loop
- Fix transition=0 not saving (parseFloat("0") || 0.5 was falsy)
- Fix dashboard showing "Key Colors" for HA targets — now "Home Assistant"
- Fix dashboard FPS showing 0/2 — HA targets show target/target
- Add CSS source subtitle to HA target dashboard cards
This commit is contained in:
2026-03-28 16:03:06 +03:00
parent 3e6760f726
commit 381ee75371
18 changed files with 341 additions and 567 deletions
@@ -62,6 +62,7 @@ def _target_to_response(target) -> OutputTargetResponse:
target_type=target.target_type,
ha_source_id=target.ha_source_id,
color_strip_source_id=target.color_strip_source_id,
brightness_value_source_id=target.brightness_value_source_id or "",
ha_light_mappings=[
HALightMappingSchema(
entity_id=m.entity_id,
@@ -26,6 +26,7 @@ class HALightTargetProcessor(TargetProcessor):
target_id: str,
ha_source_id: str,
color_strip_source_id: str = "",
brightness_value_source_id: str = "",
light_mappings: Optional[List[HALightMapping]] = None,
update_rate: float = 2.0,
transition: float = 0.5,
@@ -36,6 +37,7 @@ class HALightTargetProcessor(TargetProcessor):
super().__init__(target_id, ctx)
self._ha_source_id = ha_source_id
self._css_id = color_strip_source_id
self._brightness_vs_id = brightness_value_source_id
self._light_mappings = light_mappings or []
self._update_rate = max(0.5, min(5.0, update_rate))
self._transition = transition
@@ -45,6 +47,7 @@ class HALightTargetProcessor(TargetProcessor):
# Runtime state
self._css_stream = None
self._ha_runtime = None
self._value_stream = None # brightness value source stream
self._previous_colors: Dict[str, Tuple[int, int, int]] = {}
self._previous_on: Dict[str, bool] = {} # track on/off state per entity
self._start_time: Optional[float] = None
@@ -76,6 +79,14 @@ class HALightTargetProcessor(TargetProcessor):
except Exception as e:
logger.warning(f"HA light {self._target_id}: failed to acquire HA runtime: {e}")
# Acquire brightness value stream (if configured)
if self._brightness_vs_id and self._ctx.value_stream_manager:
try:
self._value_stream = self._ctx.value_stream_manager.acquire(self._brightness_vs_id)
except Exception as e:
logger.warning(f"HA light {self._target_id}: failed to acquire brightness VS: {e}")
self._value_stream = None
self._is_running = True
self._start_time = time.monotonic()
self._task = asyncio.create_task(self._processing_loop())
@@ -99,6 +110,14 @@ class HALightTargetProcessor(TargetProcessor):
pass
self._css_stream = None
# Release brightness value stream
if self._value_stream is not None and self._ctx.value_stream_manager:
try:
self._ctx.value_stream_manager.release(self._brightness_vs_id)
except Exception:
pass
self._value_stream = None
# Release HA runtime
if self._ha_runtime:
try:
@@ -201,6 +220,14 @@ class HALightTargetProcessor(TargetProcessor):
"""Average LED segments and call HA services for changed lights."""
led_count = len(colors)
# Get brightness multiplier from value source (1.0 if not configured)
vs_multiplier = 1.0
if self._value_stream is not None:
try:
vs_multiplier = self._value_stream.get_value()
except Exception:
vs_multiplier = 1.0
for mapping in self._light_mappings:
if not mapping.entity_id:
continue
@@ -220,9 +247,10 @@ class HALightTargetProcessor(TargetProcessor):
# Calculate brightness (0-255) from max channel
brightness = max(r, g, b)
# Apply brightness scale
if mapping.brightness_scale < 1.0:
brightness = int(brightness * mapping.brightness_scale)
# Apply brightness scale and value source multiplier
eff_scale = mapping.brightness_scale * vs_multiplier
if eff_scale < 1.0:
brightness = int(brightness * eff_scale)
# Check brightness threshold
should_be_on = (
@@ -456,6 +456,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
target_id: str,
ha_source_id: str,
color_strip_source_id: str = "",
brightness_value_source_id: str = "",
light_mappings=None,
update_rate: float = 2.0,
transition: float = 0.5,
@@ -472,6 +473,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
target_id=target_id,
ha_source_id=ha_source_id,
color_strip_source_id=color_strip_source_id,
brightness_value_source_id=brightness_value_source_id,
light_mappings=light_mappings or [],
update_rate=update_rate,
transition=transition,
@@ -144,9 +144,10 @@ function _updateRunningMetrics(enrichedRunning: any[]): void {
for (const target of enrichedRunning) {
const state = target.state || {};
const metrics = target.metrics || {};
const fpsCurrent = state.fps_current ?? 0;
const fpsActual = state.fps_actual != null ? state.fps_actual.toFixed(1) : '-';
const fpsTarget = state.fps_target || (target.settings || target.key_colors_settings || {}).fps || '-';
const isHA = target.target_type === 'ha_light';
const fpsTarget = state.fps_target || (target.settings || {}).fps || target.update_rate || '-';
const fpsCurrent = isHA ? fpsTarget : (state.fps_current ?? 0);
const fpsActual = isHA ? String(fpsTarget) : (state.fps_actual != null ? state.fps_actual.toFixed(1) : '-');
const errors = metrics.errors_count || 0;
// Push FPS and update chart
@@ -545,18 +546,21 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
const state = target.state || {};
const metrics = target.metrics || {};
const isLed = target.target_type === 'led' || target.target_type === 'wled';
const isHALight = target.target_type === 'ha_light';
const icon = ICON_TARGET;
const typeLabel = isLed ? t('dashboard.type.led') : t('dashboard.type.kc');
const navSubTab = isLed ? 'led-targets' : 'kc-targets';
const navSection = isLed ? 'led-targets' : 'kc-targets';
const navAttr = isLed ? 'data-target-id' : 'data-kc-target-id';
const typeLabel = isLed ? t('dashboard.type.led') : isHALight ? t('ha_light.section.title') : t('dashboard.type.kc');
const navSubTab = isHALight ? 'ha-light-targets' : 'led-targets';
const navSection = isHALight ? 'ha-light-targets' : 'led-targets';
const navAttr = isHALight ? 'data-ha-target-id' : 'data-target-id';
const navOnclick = `if(!event.target.closest('button')){navigateToCard('targets','${navSubTab}','${navSection}','${navAttr}','${target.id}')}`;
let subtitleParts = [typeLabel];
if (isLed) {
const device = target.device_id ? devicesMap[target.device_id] : null;
if (device) {
subtitleParts.push((device.device_type || '').toUpperCase());
if (isLed || isHALight) {
if (isLed) {
const device = target.device_id ? devicesMap[target.device_id] : null;
if (device) {
subtitleParts.push((device.device_type || '').toUpperCase());
}
}
const cssId = target.color_strip_source_id || '';
if (cssId) {
@@ -568,9 +572,9 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
}
if (isRunning) {
const fpsCurrent = state.fps_current ?? 0;
const fpsActual = state.fps_actual != null ? state.fps_actual.toFixed(1) : '-';
const fpsTarget = state.fps_target || (target.settings || target.key_colors_settings || {}).fps || '-';
const fpsTarget = state.fps_target || (target.settings || {}).fps || target.update_rate || '-';
const fpsCurrent = isHALight ? fpsTarget : (state.fps_current ?? 0);
const fpsActual = isHALight ? String(fpsTarget) : (state.fps_actual != null ? state.fps_actual.toFixed(1) : '-');
const uptime = formatUptime(metrics.uptime_seconds);
const errors = metrics.errors_count || 0;
@@ -347,7 +347,8 @@ export async function saveHALightEditor(): Promise<void> {
const haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value;
const cssSourceId = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value;
const updateRate = parseFloat((document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value) || 2.0;
const transition = parseFloat((document.getElementById('ha-light-editor-transition') as HTMLInputElement).value) || 0.5;
const transitionRaw = parseFloat((document.getElementById('ha-light-editor-transition') as HTMLInputElement).value);
const transition = isNaN(transitionRaw) ? 0.5 : transitionRaw;
const description = (document.getElementById('ha-light-editor-description') as HTMLInputElement).value.trim() || null;
if (!name) {
@@ -45,6 +45,7 @@ class HALightOutputTarget(OutputTarget):
ha_source_id: str = "" # references HomeAssistantSource
color_strip_source_id: str = "" # CSS providing the colors
brightness_value_source_id: str = "" # dynamic brightness multiplier
light_mappings: List[HALightMapping] = field(default_factory=list)
update_rate: float = 2.0 # Hz (calls per second, 0.5-5.0)
transition: float = 0.5 # HA transition seconds (smooth fade between colors)
@@ -58,6 +59,7 @@ class HALightOutputTarget(OutputTarget):
target_id=self.id,
ha_source_id=self.ha_source_id,
color_strip_source_id=self.color_strip_source_id,
brightness_value_source_id=self.brightness_value_source_id,
light_mappings=self.light_mappings,
update_rate=self.update_rate,
transition=self.transition,
@@ -96,6 +98,7 @@ class HALightOutputTarget(OutputTarget):
name=None,
ha_source_id=None,
color_strip_source_id=None,
brightness_value_source_id=None,
light_mappings=None,
update_rate=None,
transition=None,
@@ -113,6 +116,10 @@ class HALightOutputTarget(OutputTarget):
self.color_strip_source_id = _resolve_ref(
color_strip_source_id, self.color_strip_source_id
)
if brightness_value_source_id is not None:
self.brightness_value_source_id = _resolve_ref(
brightness_value_source_id, self.brightness_value_source_id
)
if light_mappings is not None:
self.light_mappings = light_mappings
if update_rate is not None:
@@ -128,6 +135,7 @@ class HALightOutputTarget(OutputTarget):
d = super().to_dict()
d["ha_source_id"] = self.ha_source_id
d["color_strip_source_id"] = self.color_strip_source_id
d["brightness_value_source_id"] = self.brightness_value_source_id
d["light_mappings"] = [m.to_dict() for m in self.light_mappings]
d["update_rate"] = self.update_rate
d["transition"] = self.transition
@@ -144,6 +152,7 @@ class HALightOutputTarget(OutputTarget):
target_type="ha_light",
ha_source_id=data.get("ha_source_id", ""),
color_strip_source_id=data.get("color_strip_source_id", ""),
brightness_value_source_id=data.get("brightness_value_source_id", ""),
light_mappings=mappings,
update_rate=data.get("update_rate", 2.0),
transition=data.get("transition", 0.5),
+34 -155
View File
@@ -1,14 +1,11 @@
"""Cross-platform asynchronous sound playback for notification alerts.
Windows: uses winsound.PlaySound (stdlib, no dependencies).
Linux: uses paplay (PulseAudio) or aplay (ALSA) via subprocess.
Uses just_playback (backed by miniaudio) for MP3/WAV/OGG/FLAC playback
with native volume control on all platforms.
All playback is fire-and-forget on a background thread. A new notification
sound cancels any currently playing sound to prevent overlap.
A new notification sound cancels any currently playing sound to prevent overlap.
"""
import subprocess
import sys
import threading
from pathlib import Path
@@ -16,133 +13,29 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Lock + handle for cancelling previous sound
_play_lock = threading.Lock()
_current_process: subprocess.Popen | None = None
# Hold reference to SND_MEMORY buffer to prevent GC during async playback
_win_sound_buf: bytes | None = None
_lock = threading.Lock()
_playback = None # lazy-init on first use
def _scale_wav_volume(file_path: Path, volume: float) -> bytes | None:
"""Read a WAV file and return a volume-scaled WAV as bytes.
def _get_playback():
"""Lazy-init the Playback singleton (import is heavy, defer until needed)."""
global _playback
if _playback is None:
from just_playback import Playback
Uses stdlib wave + struct to scale PCM samples in memory.
Returns None on error or if the format is unsupported.
"""
import io
import struct
import wave
try:
with wave.open(str(file_path), "rb") as wf:
n_channels = wf.getnchannels()
sample_width = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
raw = wf.readframes(n_frames)
except Exception as e:
logger.debug(f"Failed to read WAV for volume scaling: {e}")
return None
if sample_width not in (1, 2):
return None # Only 8-bit and 16-bit PCM supported
# Scale samples
if sample_width == 2:
fmt = f"<{len(raw) // 2}h"
samples = struct.unpack(fmt, raw)
scaled = struct.pack(fmt, *(max(-32768, min(32767, int(s * volume))) for s in samples))
else:
# 8-bit WAV is unsigned, center at 128
samples = struct.unpack(f"{len(raw)}B", raw)
scaled = struct.pack(
f"{len(raw)}B",
*(max(0, min(255, int((s - 128) * volume + 128))) for s in samples),
)
# Write scaled WAV to memory buffer
buf = io.BytesIO()
with wave.open(buf, "wb") as out:
out.setnchannels(n_channels)
out.setsampwidth(sample_width)
out.setframerate(framerate)
out.writeframes(scaled)
return buf.getvalue()
def _play_windows(file_path: Path, volume: float) -> None:
"""Play a WAV file on Windows using winsound with volume scaling."""
import winsound
global _win_sound_buf
try:
if volume < 1.0:
wav_data = _scale_wav_volume(file_path, volume)
if wav_data:
# Keep a global reference so GC doesn't free the buffer
# while async playback is still using it
_win_sound_buf = wav_data
winsound.PlaySound(wav_data, winsound.SND_MEMORY | winsound.SND_ASYNC)
return
# Full volume or fallback: play file directly
_win_sound_buf = None
winsound.PlaySound(str(file_path), winsound.SND_FILENAME | winsound.SND_ASYNC)
except Exception as e:
logger.error(f"winsound playback failed: {e}")
def _play_linux(file_path: Path, volume: float) -> None:
"""Play a sound file on Linux using paplay or aplay."""
global _current_process
# Cancel previous sound
with _play_lock:
if _current_process is not None:
try:
_current_process.terminate()
except OSError as e:
logger.debug("Failed to terminate previous sound process: %s", e)
pass
_current_process = None
try:
# Try paplay first (PulseAudio/PipeWire) — supports volume
pa_volume = max(0, min(65536, int(volume * 65536)))
proc = subprocess.Popen(
["paplay", f"--volume={pa_volume}", str(file_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
try:
# Fallback to aplay (ALSA) — no volume control
proc = subprocess.Popen(
["aplay", "-q", str(file_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
logger.warning("Neither paplay nor aplay found — cannot play notification sound")
return
with _play_lock:
_current_process = proc
# Wait for completion
proc.wait()
with _play_lock:
if _current_process is proc:
_current_process = None
_playback = Playback()
return _playback
def play_sound_async(file_path: Path, volume: float = 1.0) -> None:
"""Play a sound file asynchronously (fire-and-forget).
"""Play a sound file (non-blocking).
Supports WAV, MP3, OGG, FLAC. A new call stops any currently playing sound.
just_playback.play() is inherently non-blocking (miniaudio backend thread).
Args:
file_path: Path to the sound file (.wav).
volume: Volume level 0.0-1.0 (best-effort, not all backends support it).
file_path: Path to the sound file.
volume: Volume level 0.0-1.0.
"""
if not file_path.exists():
logger.warning(f"Sound file not found: {file_path}")
@@ -150,37 +43,23 @@ def play_sound_async(file_path: Path, volume: float = 1.0) -> None:
volume = max(0.0, min(1.0, volume))
if sys.platform == "win32":
player = _play_windows
else:
player = _play_linux
thread = threading.Thread(
target=player,
args=(file_path, volume),
name="sound-player",
daemon=True,
)
thread.start()
with _lock:
try:
pb = _get_playback()
if pb.active:
pb.stop()
pb.load_file(str(file_path))
pb.set_volume(volume)
pb.play()
except Exception as e:
logger.error(f"Sound playback failed: {e}")
def stop_current_sound() -> None:
"""Stop any currently playing notification sound."""
if sys.platform == "win32":
try:
import winsound
winsound.PlaySound(None, winsound.SND_PURGE)
except Exception as e:
logger.debug("Failed to stop winsound playback: %s", e)
pass
else:
with _play_lock:
global _current_process
if _current_process is not None:
try:
_current_process.terminate()
except OSError as e:
logger.debug("Failed to terminate sound process: %s", e)
pass
_current_process = None
with _lock:
if _playback is not None and _playback.active:
try:
_playback.stop()
except Exception as e:
logger.debug("Failed to stop playback: %s", e)