feat: HA value source test — raw value axis + behavior IconSelect
Lint & Test / test (push) Successful in 1m9s

- WS sends raw_value and raw_range for HA entity streams
- Canvas draws right-side Y-axis labels: configured min/max range
  and current raw HA value (green, positioned at correct Y)
- Replace plain <select> for scene behavior with IconSelect (moon/sun)
This commit is contained in:
2026-03-30 03:06:44 +03:00
parent 11d5d6b5e1
commit 0a8737157c
5 changed files with 191 additions and 94 deletions
@@ -384,7 +384,13 @@ async def test_value_source_ws(
try:
while True:
value = stream.get_value()
await websocket.send_json({"value": round(value, 4)})
msg: dict = {"value": round(value, 4)}
if hasattr(stream, "get_raw_value"):
raw = stream.get_raw_value()
if raw is not None:
msg["raw_value"] = round(raw, 4)
msg["raw_range"] = [stream._min_ha, stream._max_ha]
await websocket.send_json(msg)
await asyncio.sleep(0.05)
except WebSocketDisconnect:
logger.debug("Value source test WebSocket disconnected for %s", source_id)
@@ -20,6 +20,7 @@ ValueStreamManager owns all running ValueStreams, keyed by
from __future__ import annotations
import asyncio
import math
import time
from abc import ABC, abstractmethod
@@ -802,28 +803,43 @@ class HAEntityValueStream(ValueStream):
self._smoothing = smoothing
self._ha_manager = ha_manager
self._prev_value: Optional[float] = None
self._raw_value: Optional[float] = None
def start(self) -> None:
if self._ha_manager and self._ha_source_id:
try:
self._ha_manager.acquire(self._ha_source_id)
logger.info(
"HAEntityValueStream started (ha=%s, entity=%s, attr=%s)",
self._ha_source_id,
self._entity_id,
self._attribute or "<state>",
)
loop = asyncio.get_event_loop()
loop.create_task(self._async_start())
except Exception as e:
logger.warning("HAEntityValueStream failed to acquire HA runtime: %s", e)
logger.warning("HAEntityValueStream failed to schedule start: %s", e)
async def _async_start(self) -> None:
try:
await self._ha_manager.acquire(self._ha_source_id)
logger.info(
"HAEntityValueStream started (ha=%s, entity=%s, attr=%s)",
self._ha_source_id,
self._entity_id,
self._attribute or "<state>",
)
except Exception as e:
logger.warning("HAEntityValueStream failed to acquire HA runtime: %s", e)
def stop(self) -> None:
if self._ha_manager and self._ha_source_id:
try:
self._ha_manager.release(self._ha_source_id)
loop = asyncio.get_event_loop()
loop.create_task(self._async_stop())
except Exception as e:
logger.warning("HAEntityValueStream failed to release HA runtime: %s", e)
logger.warning("HAEntityValueStream failed to schedule stop: %s", e)
self._prev_value = None
async def _async_stop(self) -> None:
try:
await self._ha_manager.release(self._ha_source_id)
except Exception as e:
logger.warning("HAEntityValueStream failed to release HA runtime: %s", e)
def get_value(self) -> float:
if self._ha_manager is None:
return self._prev_value if self._prev_value is not None else 0.0
@@ -843,6 +859,8 @@ class HAEntityValueStream(ValueStream):
except (ValueError, TypeError):
return self._prev_value if self._prev_value is not None else 0.0
self._raw_value = raw
# Normalize to [0, 1]
ha_range = self._max_ha - self._min_ha
if abs(ha_range) < 1e-9:
@@ -859,6 +877,10 @@ class HAEntityValueStream(ValueStream):
self._prev_value = normalized
return normalized
def get_raw_value(self) -> Optional[float]:
"""Return the last raw HA entity value before normalization."""
return self._raw_value
def update_source(self, source: "ValueSource") -> None:
from wled_controller.storage.value_source import HAEntityValueSource
@@ -876,10 +898,17 @@ class HAEntityValueStream(ValueStream):
# If HA source changed, swap runtime
if source.ha_source_id != old_ha_source and self._ha_manager:
try:
self._ha_manager.release(old_ha_source)
self._ha_manager.acquire(self._ha_source_id)
loop = asyncio.get_event_loop()
loop.create_task(self._async_swap_runtime(old_ha_source))
except Exception as e:
logger.warning("HAEntityValueStream failed to swap HA runtime: %s", e)
logger.warning("HAEntityValueStream failed to schedule runtime swap: %s", e)
async def _async_swap_runtime(self, old_ha_source: str) -> None:
try:
await self._ha_manager.release(old_ha_source)
await self._ha_manager.acquire(self._ha_source_id)
except Exception as e:
logger.warning("HAEntityValueStream failed to swap HA runtime: %s", e)
# ---------------------------------------------------------------------------
@@ -88,6 +88,7 @@
align-items: center;
padding: 10px 0 0;
font-family: monospace;
flex-wrap: wrap;
}
.vs-test-stat {
@@ -47,6 +47,7 @@ let _vsGradientInputEntitySelect: EntitySelect | null = null;
let _vsGradientEntitySelect: EntitySelect | null = null;
let _vsCSSSourceEntitySelect: EntitySelect | null = null;
let _vsGradientEasingIconSelect: IconSelect | null = null;
let _vsBehaviorIconSelect: IconSelect | null = null;
let _vsTagsInput: TagInput | null = null;
class ValueSourceModal extends Modal {
@@ -61,6 +62,7 @@ class ValueSourceModal extends Modal {
if (_vsGradientEntitySelect) { _vsGradientEntitySelect.destroy(); _vsGradientEntitySelect = null; }
if (_vsCSSSourceEntitySelect) { _vsCSSSourceEntitySelect.destroy(); _vsCSSSourceEntitySelect = null; }
if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; }
if (_vsBehaviorIconSelect) { _vsBehaviorIconSelect.destroy(); _vsBehaviorIconSelect = null; }
}
snapshotValues() {
@@ -289,6 +291,17 @@ function _ensureAudioModeIconSelect() {
_audioModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any);
}
function _ensureBehaviorIconSelect() {
const sel = document.getElementById('value-source-scene-behavior') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'complement', icon: _icon(P.moon), label: t('value_source.scene_behavior.complement'), desc: t('value_source.scene_behavior.hint') },
{ value: 'match', icon: _icon(P.sun), label: t('value_source.scene_behavior.match') },
];
if (_vsBehaviorIconSelect) { _vsBehaviorIconSelect.updateItems(items); return; }
_vsBehaviorIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
}
function _ensureVSTypeIconSelect() {
const sel = document.getElementById('value-source-type');
if (!sel) return;
@@ -497,6 +510,7 @@ export function onValueSourceTypeChange() {
if (type === 'audio') _ensureAudioModeIconSelect();
(document.getElementById('value-source-adaptive-time-section') as HTMLElement).style.display = type === 'adaptive_time' ? '' : 'none';
(document.getElementById('value-source-adaptive-scene-section') as HTMLElement).style.display = type === 'adaptive_scene' ? '' : 'none';
if (type === 'adaptive_scene') _ensureBehaviorIconSelect();
(document.getElementById('value-source-daylight-section') as HTMLElement).style.display = type === 'daylight' ? '' : 'none';
(document.getElementById('value-source-static-color-section') as HTMLElement).style.display = type === 'static_color' ? '' : 'none';
(document.getElementById('value-source-animated-color-section') as HTMLElement).style.display = type === 'animated_color' ? '' : 'none';
@@ -744,6 +758,8 @@ let _testVsLatest: any = null;
let _testVsHistory: number[] = [];
let _testVsMinObserved = Infinity;
let _testVsMaxObserved = -Infinity;
let _testVsRawLatest: number | null = null;
let _testVsRawRange: [number, number] | null = null;
const testVsModal = new Modal('test-value-source-modal', { backdrop: true, lock: true });
@@ -759,6 +775,8 @@ export function testValueSource(sourceId: any) {
_testVsHistory = [];
_testVsMinObserved = Infinity;
_testVsMaxObserved = -Infinity;
_testVsRawLatest = null;
_testVsRawRange = null;
const currentEl = document.getElementById('vs-test-current');
const minEl = document.getElementById('vs-test-min');
@@ -794,6 +812,10 @@ export function testValueSource(sourceId: any) {
}
if (data.value < _testVsMinObserved) _testVsMinObserved = data.value;
if (data.value > _testVsMaxObserved) _testVsMaxObserved = data.value;
if (data.raw_value !== undefined) {
_testVsRawLatest = data.raw_value;
if (data.raw_range) _testVsRawRange = data.raw_range;
}
} catch (e) {
console.error('Value source test WS parse error:', e);
return;
@@ -929,6 +951,46 @@ function _renderVsChart() {
if (_testVsMaxObserved !== -Infinity && mxEl) {
mxEl.textContent = (_testVsMaxObserved * 100).toFixed(1) + '%';
}
// Draw right-side Y-axis labels for raw HA values
if (_testVsRawRange) {
const [rawMin, rawMax] = _testVsRawRange;
const rawMid = (rawMin + rawMax) / 2;
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
ctx.fillText(_fmtRaw(rawMax), w - 4, 12);
ctx.fillText(_fmtRaw(rawMid), w - 4, h / 2 - 2);
ctx.fillText(_fmtRaw(rawMin), w - 4, h - 4);
// Draw current raw value marker on right edge
if (_testVsRawLatest !== null) {
const rawRange = rawMax - rawMin;
const frac = rawRange !== 0 ? (_testVsRawLatest - rawMin) / rawRange : 0.5;
const clampedFrac = Math.max(0, Math.min(1, frac));
const yRaw = h - clampedFrac * h;
ctx.fillStyle = '#4caf50';
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'right';
// Offset label to avoid overlap with top/bottom labels
const labelY = Math.max(20, Math.min(h - 12, yRaw + 4));
ctx.fillText(_fmtRaw(_testVsRawLatest), w - 4, labelY);
// Small tick mark
ctx.strokeStyle = '#4caf50';
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(w - 2, yRaw);
ctx.lineTo(w, yRaw);
ctx.stroke();
}
}
}
function _fmtRaw(v: number): string {
return Number.isInteger(v) ? String(v) : v.toFixed(1);
}
// ── Card rendering (used by streams.js) ───────────────────────
@@ -366,7 +366,7 @@
"tour.targets": "Targets — add WLED devices, configure LED targets with capture settings and calibration.",
"tour.sources": "Sources — manage capture templates, picture sources, audio sources, and color strips.",
"tour.graph": "Graph — visual overview of all entities and their connections. Drag ports to connect, right-click edges to disconnect.",
"tour.automations": "Automations — automate scene switching with time, audio, or value conditions.",
"tour.automations": "Automations — automate scene switching with time, audio, or value rules.",
"tour.settings": "Settings — backup and restore configuration, manage auto-backups.",
"tour.api": "API Docs — interactive REST API documentation powered by Swagger.",
"tour.search": "Search — quickly find and navigate to any entity with Ctrl+K.",
@@ -389,11 +389,11 @@
"tour.src.processed": "Processed — apply post-processing effects like blur, brightness, or color correction.",
"tour.src.color_strip": "Color Strips — define how screen regions map to LED segments.",
"tour.src.audio": "Audio — analyze microphone or system audio for reactive LED effects.",
"tour.src.value": "Value — numeric data sources used as conditions in automations.",
"tour.src.value": "Value — numeric data sources used as rules in automations.",
"tour.src.sync": "Sync Clocks — shared timers that synchronize animations across multiple sources.",
"tour.auto.list": "Automations — automate scene activation based on time, audio, or value conditions.",
"tour.auto.add": "Click + to create a new automation with conditions and a scene to activate.",
"tour.auto.card": "Each card shows automation status, conditions, and quick controls to edit or toggle.",
"tour.auto.list": "Automations — automate scene activation based on time, audio, or value rules.",
"tour.auto.add": "Click + to create a new automation with rules and a scene to activate.",
"tour.auto.card": "Each card shows automation status, rules, and quick controls to edit or toggle.",
"tour.auto.scenes_list": "Scenes — saved system states that automations can activate or you can apply manually.",
"tour.auto.scenes_add": "Click + to capture the current system state as a new scene preset.",
"tour.auto.scenes_card": "Each scene card shows target/device counts. Click to edit, recapture, or activate.",
@@ -451,6 +451,8 @@
"palette.search": "Search…",
"section.filter.placeholder": "Filter...",
"section.filter.reset": "Clear filter",
"section.hide": "Hide",
"section.show_hidden": "Show hidden cards",
"tags.label": "Tags",
"tags.hint": "Assign tags for grouping and filtering cards",
"tags.placeholder": "Add tag...",
@@ -739,71 +741,68 @@
"automations.name.placeholder": "My Automation",
"automations.enabled": "Enabled:",
"automations.enabled.hint": "Disabled automations won't activate even when conditions are met",
"automations.condition_logic": "Condition Logic:",
"automations.condition_logic.hint": "How multiple conditions are combined: ANY (OR) or ALL (AND)",
"automations.condition_logic.or": "Any condition (OR)",
"automations.condition_logic.and": "All conditions (AND)",
"automations.condition_logic.or.desc": "Triggers when any condition matches",
"automations.condition_logic.and.desc": "Triggers only when all match",
"automations.conditions": "Conditions:",
"automations.conditions.hint": "Rules that determine when this automation activates",
"automations.conditions.add": "Add Condition",
"automations.conditions.empty": "No conditions — automation is always active when enabled",
"automations.condition.always": "Always",
"automations.condition.always.desc": "Always active",
"automations.condition.always.hint": "Automation activates immediately when enabled and stays active.",
"automations.condition.startup": "Startup",
"automations.condition.startup.desc": "On server start",
"automations.condition.startup.hint": "Activates when the server starts and stays active while enabled.",
"automations.condition.application": "Application",
"automations.condition.application.desc": "App running/focused",
"automations.condition.application.apps": "Applications:",
"automations.condition.application.apps.hint": "Process names, one per line (e.g. firefox.exe)",
"automations.condition.application.browse": "Browse",
"automations.condition.application.search": "Filter processes...",
"automations.condition.application.no_processes": "No processes found",
"automations.condition.application.match_type": "Match Type:",
"automations.condition.application.match_type.hint": "How to detect the application",
"automations.condition.application.match_type.running": "Running",
"automations.condition.application.match_type.running.desc": "Process is active",
"automations.condition.application.match_type.topmost": "Topmost",
"automations.condition.application.match_type.topmost.desc": "Foreground window",
"automations.condition.application.match_type.topmost_fullscreen": "Topmost + FS",
"automations.condition.application.match_type.topmost_fullscreen.desc": "Foreground + fullscreen",
"automations.condition.application.match_type.fullscreen": "Fullscreen",
"automations.condition.application.match_type.fullscreen.desc": "Any fullscreen app",
"automations.condition.time_of_day": "Time of Day",
"automations.condition.time_of_day.desc": "Time range",
"automations.condition.time_of_day.start_time": "Start Time:",
"automations.condition.time_of_day.end_time": "End Time:",
"automations.condition.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:0006:00), set start time after end time.",
"automations.condition.system_idle": "System Idle",
"automations.condition.system_idle.desc": "User idle/active",
"automations.condition.system_idle.idle_minutes": "Idle Timeout (minutes):",
"automations.condition.system_idle.mode": "Trigger Mode:",
"automations.condition.system_idle.when_idle": "When idle",
"automations.condition.system_idle.when_active": "When active",
"automations.condition.display_state": "Display State",
"automations.condition.display_state.desc": "Monitor on/off",
"automations.condition.display_state.state": "Monitor State:",
"automations.condition.display_state.on": "On",
"automations.condition.display_state.off": "Off (sleeping)",
"automations.condition.mqtt": "MQTT",
"automations.condition.mqtt.desc": "MQTT message",
"automations.condition.mqtt.topic": "Topic:",
"automations.condition.mqtt.payload": "Payload:",
"automations.condition.mqtt.match_mode": "Match Mode:",
"automations.condition.mqtt.match_mode.exact": "Exact",
"automations.condition.mqtt.match_mode.contains": "Contains",
"automations.condition.mqtt.match_mode.regex": "Regex",
"automations.condition.mqtt.hint": "Activate when an MQTT topic receives a matching payload",
"automations.condition.webhook": "Webhook",
"automations.condition.webhook.desc": "HTTP callback",
"automations.condition.webhook.hint": "Activate via an HTTP call from external services (Home Assistant, IFTTT, curl, etc.)",
"automations.condition.webhook.url": "Webhook URL:",
"automations.condition.webhook.copy": "Copy",
"automations.condition.webhook.copied": "Copied!",
"automations.condition.webhook.save_first": "Save the automation first to generate a webhook URL",
"automations.rule_logic": "Rule Logic:",
"automations.rule_logic.hint": "How multiple rules are combined: ANY (OR) or ALL (AND)",
"automations.rule_logic.or": "Any rule (OR)",
"automations.rule_logic.and": "All rules (AND)",
"automations.rule_logic.or.desc": "Triggers when any rule matches",
"automations.rule_logic.and.desc": "Triggers only when all match",
"automations.rules": "Rules:",
"automations.rules.hint": "Rules that determine when this automation activates",
"automations.rules.add": "Add Rule",
"automations.rules.empty": "No rules — automation is always active when enabled",
"automations.rule.startup": "Startup",
"automations.rule.startup.desc": "On server start",
"automations.rule.startup.hint": "Activates when the server starts and stays active while enabled.",
"automations.rule.application": "Application",
"automations.rule.application.desc": "App running/focused",
"automations.rule.application.apps": "Applications:",
"automations.rule.application.apps.hint": "Process names, one per line (e.g. firefox.exe)",
"automations.rule.application.browse": "Browse",
"automations.rule.application.search": "Filter processes...",
"automations.rule.application.no_processes": "No processes found",
"automations.rule.application.match_type": "Match Type:",
"automations.rule.application.match_type.hint": "How to detect the application",
"automations.rule.application.match_type.running": "Running",
"automations.rule.application.match_type.running.desc": "Process is active",
"automations.rule.application.match_type.topmost": "Topmost",
"automations.rule.application.match_type.topmost.desc": "Foreground window",
"automations.rule.application.match_type.topmost_fullscreen": "Topmost + FS",
"automations.rule.application.match_type.topmost_fullscreen.desc": "Foreground + fullscreen",
"automations.rule.application.match_type.fullscreen": "Fullscreen",
"automations.rule.application.match_type.fullscreen.desc": "Any fullscreen app",
"automations.rule.time_of_day": "Time of Day",
"automations.rule.time_of_day.desc": "Time range",
"automations.rule.time_of_day.start_time": "Start Time:",
"automations.rule.time_of_day.end_time": "End Time:",
"automations.rule.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:0006:00), set start time after end time.",
"automations.rule.system_idle": "System Idle",
"automations.rule.system_idle.desc": "User idle/active",
"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_active": "When active",
"automations.rule.display_state": "Display State",
"automations.rule.display_state.desc": "Monitor on/off",
"automations.rule.display_state.state": "Monitor State:",
"automations.rule.display_state.on": "On",
"automations.rule.display_state.off": "Off (sleeping)",
"automations.rule.mqtt": "MQTT",
"automations.rule.mqtt.desc": "MQTT message",
"automations.rule.mqtt.topic": "Topic:",
"automations.rule.mqtt.payload": "Payload:",
"automations.rule.mqtt.match_mode": "Match Mode:",
"automations.rule.mqtt.match_mode.exact": "Exact",
"automations.rule.mqtt.match_mode.contains": "Contains",
"automations.rule.mqtt.match_mode.regex": "Regex",
"automations.rule.mqtt.hint": "Activate when an MQTT topic receives a matching payload",
"automations.rule.webhook": "Webhook",
"automations.rule.webhook.desc": "HTTP callback",
"automations.rule.webhook.hint": "Activate via an HTTP call from external services (Home Assistant, IFTTT, curl, etc.)",
"automations.rule.webhook.url": "Webhook URL:",
"automations.rule.webhook.copy": "Copy",
"automations.rule.webhook.copied": "Copied!",
"automations.rule.webhook.save_first": "Save the automation first to generate a webhook URL",
"automations.scene": "Scene:",
"automations.scene.hint": "Scene preset to activate when conditions are met",
"automations.scene.search_placeholder": "Search scenes...",
@@ -1881,16 +1880,16 @@
"ha_light.mapping.select_entity": "Select a light entity...",
"ha_light.mapping.search_entity": "Search light entities...",
"section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.",
"automations.condition.home_assistant": "Home Assistant",
"automations.condition.home_assistant.desc": "HA entity state",
"automations.condition.home_assistant.ha_source": "HA Source:",
"automations.condition.home_assistant.entity_id": "Entity ID:",
"automations.condition.home_assistant.state": "State:",
"automations.condition.home_assistant.match_mode": "Match Mode:",
"automations.condition.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state",
"automations.condition.ha.match_mode.exact.desc": "State must match exactly",
"automations.condition.ha.match_mode.contains.desc": "State must contain the text",
"automations.condition.ha.match_mode.regex.desc": "State must match the regex pattern",
"automations.rule.home_assistant": "Home Assistant",
"automations.rule.home_assistant.desc": "HA entity state",
"automations.rule.home_assistant.ha_source": "HA Source:",
"automations.rule.home_assistant.entity_id": "Entity ID:",
"automations.rule.home_assistant.state": "State:",
"automations.rule.home_assistant.match_mode": "Match Mode:",
"automations.rule.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state",
"automations.rule.ha.match_mode.exact.desc": "State must match exactly",
"automations.rule.ha.match_mode.contains.desc": "State must contain the text",
"automations.rule.ha.match_mode.regex.desc": "State must match the regex pattern",
"color_strip.clock": "Sync Clock:",
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.",
"graph.title": "Graph",