diff --git a/server/src/wled_controller/api/routes/value_sources.py b/server/src/wled_controller/api/routes/value_sources.py index c147f4c..c6a67bd 100644 --- a/server/src/wled_controller/api/routes/value_sources.py +++ b/server/src/wled_controller/api/routes/value_sources.py @@ -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) diff --git a/server/src/wled_controller/core/processing/value_stream.py b/server/src/wled_controller/core/processing/value_stream.py index 1066a9b..54b8710 100644 --- a/server/src/wled_controller/core/processing/value_stream.py +++ b/server/src/wled_controller/core/processing/value_stream.py @@ -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 "", - ) + 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 "", + ) + 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) # --------------------------------------------------------------------------- diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index a6032da..8602dd7 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -88,6 +88,7 @@ align-items: center; padding: 10px 0 0; font-family: monospace; + flex-wrap: wrap; } .vs-test-stat { diff --git a/server/src/wled_controller/static/js/features/value-sources.ts b/server/src/wled_controller/static/js/features/value-sources.ts index 7c5ba55..03e27a7 100644 --- a/server/src/wled_controller/static/js/features/value-sources.ts +++ b/server/src/wled_controller/static/js/features/value-sources.ts @@ -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) ─────────────────────── diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 048b5e4..04fee6d 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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:00–06: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:00–06: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",