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: try:
while True: while True:
value = stream.get_value() 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) await asyncio.sleep(0.05)
except WebSocketDisconnect: except WebSocketDisconnect:
logger.debug("Value source test WebSocket disconnected for %s", source_id) 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 from __future__ import annotations
import asyncio
import math import math
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
@@ -802,28 +803,43 @@ class HAEntityValueStream(ValueStream):
self._smoothing = smoothing self._smoothing = smoothing
self._ha_manager = ha_manager self._ha_manager = ha_manager
self._prev_value: Optional[float] = None self._prev_value: Optional[float] = None
self._raw_value: Optional[float] = None
def start(self) -> None: def start(self) -> None:
if self._ha_manager and self._ha_source_id: if self._ha_manager and self._ha_source_id:
try: try:
self._ha_manager.acquire(self._ha_source_id) loop = asyncio.get_event_loop()
logger.info( loop.create_task(self._async_start())
"HAEntityValueStream started (ha=%s, entity=%s, attr=%s)",
self._ha_source_id,
self._entity_id,
self._attribute or "<state>",
)
except Exception as e: 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: def stop(self) -> None:
if self._ha_manager and self._ha_source_id: if self._ha_manager and self._ha_source_id:
try: try:
self._ha_manager.release(self._ha_source_id) loop = asyncio.get_event_loop()
loop.create_task(self._async_stop())
except Exception as e: 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 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: def get_value(self) -> float:
if self._ha_manager is None: if self._ha_manager is None:
return self._prev_value if self._prev_value is not None else 0.0 return self._prev_value if self._prev_value is not None else 0.0
@@ -843,6 +859,8 @@ class HAEntityValueStream(ValueStream):
except (ValueError, TypeError): except (ValueError, TypeError):
return self._prev_value if self._prev_value is not None else 0.0 return self._prev_value if self._prev_value is not None else 0.0
self._raw_value = raw
# Normalize to [0, 1] # Normalize to [0, 1]
ha_range = self._max_ha - self._min_ha ha_range = self._max_ha - self._min_ha
if abs(ha_range) < 1e-9: if abs(ha_range) < 1e-9:
@@ -859,6 +877,10 @@ class HAEntityValueStream(ValueStream):
self._prev_value = normalized self._prev_value = normalized
return 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: def update_source(self, source: "ValueSource") -> None:
from wled_controller.storage.value_source import HAEntityValueSource from wled_controller.storage.value_source import HAEntityValueSource
@@ -876,10 +898,17 @@ class HAEntityValueStream(ValueStream):
# If HA source changed, swap runtime # If HA source changed, swap runtime
if source.ha_source_id != old_ha_source and self._ha_manager: if source.ha_source_id != old_ha_source and self._ha_manager:
try: try:
self._ha_manager.release(old_ha_source) loop = asyncio.get_event_loop()
self._ha_manager.acquire(self._ha_source_id) loop.create_task(self._async_swap_runtime(old_ha_source))
except Exception as e: 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; align-items: center;
padding: 10px 0 0; padding: 10px 0 0;
font-family: monospace; font-family: monospace;
flex-wrap: wrap;
} }
.vs-test-stat { .vs-test-stat {
@@ -47,6 +47,7 @@ let _vsGradientInputEntitySelect: EntitySelect | null = null;
let _vsGradientEntitySelect: EntitySelect | null = null; let _vsGradientEntitySelect: EntitySelect | null = null;
let _vsCSSSourceEntitySelect: EntitySelect | null = null; let _vsCSSSourceEntitySelect: EntitySelect | null = null;
let _vsGradientEasingIconSelect: IconSelect | null = null; let _vsGradientEasingIconSelect: IconSelect | null = null;
let _vsBehaviorIconSelect: IconSelect | null = null;
let _vsTagsInput: TagInput | null = null; let _vsTagsInput: TagInput | null = null;
class ValueSourceModal extends Modal { class ValueSourceModal extends Modal {
@@ -61,6 +62,7 @@ class ValueSourceModal extends Modal {
if (_vsGradientEntitySelect) { _vsGradientEntitySelect.destroy(); _vsGradientEntitySelect = null; } if (_vsGradientEntitySelect) { _vsGradientEntitySelect.destroy(); _vsGradientEntitySelect = null; }
if (_vsCSSSourceEntitySelect) { _vsCSSSourceEntitySelect.destroy(); _vsCSSSourceEntitySelect = null; } if (_vsCSSSourceEntitySelect) { _vsCSSSourceEntitySelect.destroy(); _vsCSSSourceEntitySelect = null; }
if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; } if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; }
if (_vsBehaviorIconSelect) { _vsBehaviorIconSelect.destroy(); _vsBehaviorIconSelect = null; }
} }
snapshotValues() { snapshotValues() {
@@ -289,6 +291,17 @@ function _ensureAudioModeIconSelect() {
_audioModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any); _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() { function _ensureVSTypeIconSelect() {
const sel = document.getElementById('value-source-type'); const sel = document.getElementById('value-source-type');
if (!sel) return; if (!sel) return;
@@ -497,6 +510,7 @@ export function onValueSourceTypeChange() {
if (type === 'audio') _ensureAudioModeIconSelect(); if (type === 'audio') _ensureAudioModeIconSelect();
(document.getElementById('value-source-adaptive-time-section') as HTMLElement).style.display = type === 'adaptive_time' ? '' : 'none'; (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'; (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-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-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'; (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 _testVsHistory: number[] = [];
let _testVsMinObserved = Infinity; let _testVsMinObserved = Infinity;
let _testVsMaxObserved = -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 }); const testVsModal = new Modal('test-value-source-modal', { backdrop: true, lock: true });
@@ -759,6 +775,8 @@ export function testValueSource(sourceId: any) {
_testVsHistory = []; _testVsHistory = [];
_testVsMinObserved = Infinity; _testVsMinObserved = Infinity;
_testVsMaxObserved = -Infinity; _testVsMaxObserved = -Infinity;
_testVsRawLatest = null;
_testVsRawRange = null;
const currentEl = document.getElementById('vs-test-current'); const currentEl = document.getElementById('vs-test-current');
const minEl = document.getElementById('vs-test-min'); 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 < _testVsMinObserved) _testVsMinObserved = data.value;
if (data.value > _testVsMaxObserved) _testVsMaxObserved = 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) { } catch (e) {
console.error('Value source test WS parse error:', e); console.error('Value source test WS parse error:', e);
return; return;
@@ -929,6 +951,46 @@ function _renderVsChart() {
if (_testVsMaxObserved !== -Infinity && mxEl) { if (_testVsMaxObserved !== -Infinity && mxEl) {
mxEl.textContent = (_testVsMaxObserved * 100).toFixed(1) + '%'; 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) ─────────────────────── // ── 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.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.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.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.settings": "Settings — backup and restore configuration, manage auto-backups.",
"tour.api": "API Docs — interactive REST API documentation powered by Swagger.", "tour.api": "API Docs — interactive REST API documentation powered by Swagger.",
"tour.search": "Search — quickly find and navigate to any entity with Ctrl+K.", "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.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.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.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.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.list": "Automations — automate scene activation based on time, audio, or value rules.",
"tour.auto.add": "Click + to create a new automation with conditions and a scene to activate.", "tour.auto.add": "Click + to create a new automation with rules and a scene to activate.",
"tour.auto.card": "Each card shows automation status, conditions, and quick controls to edit or toggle.", "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_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_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.", "tour.auto.scenes_card": "Each scene card shows target/device counts. Click to edit, recapture, or activate.",
@@ -451,6 +451,8 @@
"palette.search": "Search…", "palette.search": "Search…",
"section.filter.placeholder": "Filter...", "section.filter.placeholder": "Filter...",
"section.filter.reset": "Clear filter", "section.filter.reset": "Clear filter",
"section.hide": "Hide",
"section.show_hidden": "Show hidden cards",
"tags.label": "Tags", "tags.label": "Tags",
"tags.hint": "Assign tags for grouping and filtering cards", "tags.hint": "Assign tags for grouping and filtering cards",
"tags.placeholder": "Add tag...", "tags.placeholder": "Add tag...",
@@ -739,71 +741,68 @@
"automations.name.placeholder": "My Automation", "automations.name.placeholder": "My Automation",
"automations.enabled": "Enabled:", "automations.enabled": "Enabled:",
"automations.enabled.hint": "Disabled automations won't activate even when conditions are met", "automations.enabled.hint": "Disabled automations won't activate even when conditions are met",
"automations.condition_logic": "Condition Logic:", "automations.rule_logic": "Rule Logic:",
"automations.condition_logic.hint": "How multiple conditions are combined: ANY (OR) or ALL (AND)", "automations.rule_logic.hint": "How multiple rules are combined: ANY (OR) or ALL (AND)",
"automations.condition_logic.or": "Any condition (OR)", "automations.rule_logic.or": "Any rule (OR)",
"automations.condition_logic.and": "All conditions (AND)", "automations.rule_logic.and": "All rules (AND)",
"automations.condition_logic.or.desc": "Triggers when any condition matches", "automations.rule_logic.or.desc": "Triggers when any rule matches",
"automations.condition_logic.and.desc": "Triggers only when all match", "automations.rule_logic.and.desc": "Triggers only when all match",
"automations.conditions": "Conditions:", "automations.rules": "Rules:",
"automations.conditions.hint": "Rules that determine when this automation activates", "automations.rules.hint": "Rules that determine when this automation activates",
"automations.conditions.add": "Add Condition", "automations.rules.add": "Add Rule",
"automations.conditions.empty": "No conditions — automation is always active when enabled", "automations.rules.empty": "No rules — automation is always active when enabled",
"automations.condition.always": "Always", "automations.rule.startup": "Startup",
"automations.condition.always.desc": "Always active", "automations.rule.startup.desc": "On server start",
"automations.condition.always.hint": "Automation activates immediately when enabled and stays active.", "automations.rule.startup.hint": "Activates when the server starts and stays active while enabled.",
"automations.condition.startup": "Startup", "automations.rule.application": "Application",
"automations.condition.startup.desc": "On server start", "automations.rule.application.desc": "App running/focused",
"automations.condition.startup.hint": "Activates when the server starts and stays active while enabled.", "automations.rule.application.apps": "Applications:",
"automations.condition.application": "Application", "automations.rule.application.apps.hint": "Process names, one per line (e.g. firefox.exe)",
"automations.condition.application.desc": "App running/focused", "automations.rule.application.browse": "Browse",
"automations.condition.application.apps": "Applications:", "automations.rule.application.search": "Filter processes...",
"automations.condition.application.apps.hint": "Process names, one per line (e.g. firefox.exe)", "automations.rule.application.no_processes": "No processes found",
"automations.condition.application.browse": "Browse", "automations.rule.application.match_type": "Match Type:",
"automations.condition.application.search": "Filter processes...", "automations.rule.application.match_type.hint": "How to detect the application",
"automations.condition.application.no_processes": "No processes found", "automations.rule.application.match_type.running": "Running",
"automations.condition.application.match_type": "Match Type:", "automations.rule.application.match_type.running.desc": "Process is active",
"automations.condition.application.match_type.hint": "How to detect the application", "automations.rule.application.match_type.topmost": "Topmost",
"automations.condition.application.match_type.running": "Running", "automations.rule.application.match_type.topmost.desc": "Foreground window",
"automations.condition.application.match_type.running.desc": "Process is active", "automations.rule.application.match_type.topmost_fullscreen": "Topmost + FS",
"automations.condition.application.match_type.topmost": "Topmost", "automations.rule.application.match_type.topmost_fullscreen.desc": "Foreground + fullscreen",
"automations.condition.application.match_type.topmost.desc": "Foreground window", "automations.rule.application.match_type.fullscreen": "Fullscreen",
"automations.condition.application.match_type.topmost_fullscreen": "Topmost + FS", "automations.rule.application.match_type.fullscreen.desc": "Any fullscreen app",
"automations.condition.application.match_type.topmost_fullscreen.desc": "Foreground + fullscreen", "automations.rule.time_of_day": "Time of Day",
"automations.condition.application.match_type.fullscreen": "Fullscreen", "automations.rule.time_of_day.desc": "Time range",
"automations.condition.application.match_type.fullscreen.desc": "Any fullscreen app", "automations.rule.time_of_day.start_time": "Start Time:",
"automations.condition.time_of_day": "Time of Day", "automations.rule.time_of_day.end_time": "End Time:",
"automations.condition.time_of_day.desc": "Time range", "automations.rule.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:0006:00), set start time after end time.",
"automations.condition.time_of_day.start_time": "Start Time:", "automations.rule.system_idle": "System Idle",
"automations.condition.time_of_day.end_time": "End Time:", "automations.rule.system_idle.desc": "User idle/active",
"automations.condition.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:0006:00), set start time after end time.", "automations.rule.system_idle.idle_minutes": "Idle Timeout (minutes):",
"automations.condition.system_idle": "System Idle", "automations.rule.system_idle.mode": "Trigger Mode:",
"automations.condition.system_idle.desc": "User idle/active", "automations.rule.system_idle.when_idle": "When idle",
"automations.condition.system_idle.idle_minutes": "Idle Timeout (minutes):", "automations.rule.system_idle.when_active": "When active",
"automations.condition.system_idle.mode": "Trigger Mode:", "automations.rule.display_state": "Display State",
"automations.condition.system_idle.when_idle": "When idle", "automations.rule.display_state.desc": "Monitor on/off",
"automations.condition.system_idle.when_active": "When active", "automations.rule.display_state.state": "Monitor State:",
"automations.condition.display_state": "Display State", "automations.rule.display_state.on": "On",
"automations.condition.display_state.desc": "Monitor on/off", "automations.rule.display_state.off": "Off (sleeping)",
"automations.condition.display_state.state": "Monitor State:", "automations.rule.mqtt": "MQTT",
"automations.condition.display_state.on": "On", "automations.rule.mqtt.desc": "MQTT message",
"automations.condition.display_state.off": "Off (sleeping)", "automations.rule.mqtt.topic": "Topic:",
"automations.condition.mqtt": "MQTT", "automations.rule.mqtt.payload": "Payload:",
"automations.condition.mqtt.desc": "MQTT message", "automations.rule.mqtt.match_mode": "Match Mode:",
"automations.condition.mqtt.topic": "Topic:", "automations.rule.mqtt.match_mode.exact": "Exact",
"automations.condition.mqtt.payload": "Payload:", "automations.rule.mqtt.match_mode.contains": "Contains",
"automations.condition.mqtt.match_mode": "Match Mode:", "automations.rule.mqtt.match_mode.regex": "Regex",
"automations.condition.mqtt.match_mode.exact": "Exact", "automations.rule.mqtt.hint": "Activate when an MQTT topic receives a matching payload",
"automations.condition.mqtt.match_mode.contains": "Contains", "automations.rule.webhook": "Webhook",
"automations.condition.mqtt.match_mode.regex": "Regex", "automations.rule.webhook.desc": "HTTP callback",
"automations.condition.mqtt.hint": "Activate when an MQTT topic receives a matching payload", "automations.rule.webhook.hint": "Activate via an HTTP call from external services (Home Assistant, IFTTT, curl, etc.)",
"automations.condition.webhook": "Webhook", "automations.rule.webhook.url": "Webhook URL:",
"automations.condition.webhook.desc": "HTTP callback", "automations.rule.webhook.copy": "Copy",
"automations.condition.webhook.hint": "Activate via an HTTP call from external services (Home Assistant, IFTTT, curl, etc.)", "automations.rule.webhook.copied": "Copied!",
"automations.condition.webhook.url": "Webhook URL:", "automations.rule.webhook.save_first": "Save the automation first to generate a 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.scene": "Scene:", "automations.scene": "Scene:",
"automations.scene.hint": "Scene preset to activate when conditions are met", "automations.scene.hint": "Scene preset to activate when conditions are met",
"automations.scene.search_placeholder": "Search scenes...", "automations.scene.search_placeholder": "Search scenes...",
@@ -1881,16 +1880,16 @@
"ha_light.mapping.select_entity": "Select a light entity...", "ha_light.mapping.select_entity": "Select a light entity...",
"ha_light.mapping.search_entity": "Search light entities...", "ha_light.mapping.search_entity": "Search light entities...",
"section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.", "section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.",
"automations.condition.home_assistant": "Home Assistant", "automations.rule.home_assistant": "Home Assistant",
"automations.condition.home_assistant.desc": "HA entity state", "automations.rule.home_assistant.desc": "HA entity state",
"automations.condition.home_assistant.ha_source": "HA Source:", "automations.rule.home_assistant.ha_source": "HA Source:",
"automations.condition.home_assistant.entity_id": "Entity ID:", "automations.rule.home_assistant.entity_id": "Entity ID:",
"automations.condition.home_assistant.state": "State:", "automations.rule.home_assistant.state": "State:",
"automations.condition.home_assistant.match_mode": "Match Mode:", "automations.rule.home_assistant.match_mode": "Match Mode:",
"automations.condition.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state", "automations.rule.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.rule.ha.match_mode.exact.desc": "State must match exactly",
"automations.condition.ha.match_mode.contains.desc": "State must contain the text", "automations.rule.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.ha.match_mode.regex.desc": "State must match the regex pattern",
"color_strip.clock": "Sync Clock:", "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.", "color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.",
"graph.title": "Graph", "graph.title": "Graph",