refactor: key colors targets → CSS source type, HA target improvements
Lint & Test / test (push) Successful in 1m26s

Key Colors refactor:
- New `key_colors` CSS source type with inline rectangles
- KeyColorsColorStripStream: extracts N colors from screen regions
- CSS editor: EntitySelect for picture source, IconSelect for color mode
- Configure Regions button on card opens pattern canvas editor
- Live WS preview at 5 FPS with rectangle overlay + color swatches
- Removed KC target type, pattern template entity, and related API routes
- Removed KC/pattern template sections from Targets tab

HA light target improvements:
- Update rate, transition, mappings, brightness VS now editable via PUT
- Card crosslinks for HA source, CSS source, brightness VS
- HA connection status icon, text metrics (Hz, uptime)
- Brightness value source selector in editor
This commit is contained in:
2026-03-28 15:28:22 +03:00
parent 89d1b13854
commit 3e6760f726
46 changed files with 2707 additions and 789 deletions
@@ -19,15 +19,74 @@ 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
def _scale_wav_volume(file_path: Path, volume: float) -> bytes | None:
"""Read a WAV file and return a volume-scaled WAV as bytes.
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."""
"""Play a WAV file on Windows using winsound with volume scaling."""
import winsound
# winsound doesn't support volume control natively,
# but SND_ASYNC plays non-blocking within this thread
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}")
@@ -110,6 +169,7 @@ def stop_current_sound() -> None:
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)