feat(automations): expand automation rules + UI + engine coverage

Storage model + Pydantic schema + route surface gain the new rule
shapes the engine already supports. Frontend automations editor grows
the matching inputs. New core/test_automation_engine.py pins the
dispatch table rules behind ~285 lines of unit coverage.
This commit is contained in:
2026-05-23 00:48:19 +03:00
parent f03cb303c3
commit 3fe66d80cb
5 changed files with 533 additions and 3 deletions
@@ -23,6 +23,7 @@ from ledgrab.storage.automation import (
ApplicationRule,
DisplayStateRule,
HomeAssistantRule,
HTTPPollRule,
MQTTRule,
Rule,
StartupRule,
@@ -75,6 +76,11 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
state=s.state or "",
match_mode=s.match_mode or "exact",
),
"http_poll": lambda: HTTPPollRule(
value_source_id=s.value_source_id or "",
operator=s.operator or "equals",
value=s.value or "",
),
}
factory = _SCHEMA_TO_RULE.get(s.rule_type)
if factory is None:
@@ -46,6 +46,24 @@ class RuleSchema(BaseModel):
None,
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)",
)
# HTTP poll rule fields
value_source_id: Optional[str] = Field(
None,
description=(
"Value source ID (for http_poll rule). The referenced "
"ValueSource must be of source_type='http'."
),
)
operator: Optional[str] = Field(
None,
description=(
"Comparison operator for http_poll rule: "
"'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists'."
),
)
value: Optional[str] = Field(
None, description="Expected value (for http_poll rule; ignored for 'exists')"
)
# Backward-compatible alias
@@ -5,6 +5,7 @@
import {
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
scenePresetsCache, _cachedHASources, haSourcesCache,
_cachedValueSources, valueSourcesCache,
getHAEntityFriendlyName, setHAEntityNames,
} from '../core/state.ts';
import { prefetchHAEntities } from './home-assistant-sources.ts';
@@ -26,6 +27,7 @@ import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts'
import { getBaseOrigin } from './settings.ts';
import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { enhanceMiniSelects } from '../core/mini-select.ts';
import { attachProcessPicker } from '../core/process-picker.ts';
import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
@@ -106,6 +108,12 @@ class AutomationEditorModal extends Modal {
onForceClose() {
if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; }
// Tear down any per-rule portal widgets (http_poll EntitySelect /
// IconSelect) the rule rows attached. Walks every rule row in the
// modal, no-op for rows that didn't stash widgets.
document
.querySelectorAll<HTMLElement>('#automation-rules-list .rule-fields-container')
.forEach(c => _disposeHTTPPollWidgets(c));
}
snapshotValues() {
@@ -226,6 +234,7 @@ export async function loadAutomations() {
automationsCacheObj.fetch(),
scenePresetsCache.fetch(),
haSourcesCache.fetch(),
valueSourcesCache.fetch(),
]);
const sceneMap = new Map(scenes.map(s => [s.id, s]));
@@ -345,8 +354,67 @@ const RULE_CHIP_RENDERERS: Record<string, RuleChipBuilder> = {
title: tooltip,
};
},
http_poll: (c) => {
const vsId = c.value_source_id || '';
const vs = (_cachedValueSources || []).find(v => v.id === vsId);
const vsLabel = vs?.name || (vsId ? vsId : t('automations.rule.http_poll.no_source'));
const op = c.operator || 'equals';
const opGlyph = _httpOpGlyph(op);
const rhs = op === 'exists' ? '' : ` ${c.value ?? ''}`;
return {
icon: _icon(P.globe),
text: `${vsLabel} ${opGlyph}${rhs}`,
title: t('automations.rule.http_poll'),
};
},
};
type HTTPPollOp = 'equals' | 'not_equals' | 'contains' | 'regex' | 'gt' | 'lt' | 'exists';
const HTTP_OP_KEYS: HTTPPollOp[] = [
'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists',
];
const _HTTP_OP_GLYPHS: Record<HTTPPollOp, string> = {
equals: '=',
not_equals: '≠',
contains: '∈',
regex: '/.../',
gt: '>',
lt: '<',
exists: '?',
};
const _HTTP_OP_ICON_PATHS: Record<HTTPPollOp, string> = {
equals: P.check,
not_equals: P.circleOff,
contains: P.search,
regex: P.code,
gt: P.chevronUp,
lt: P.chevronDown,
exists: P.zap,
};
function _httpOpGlyph(op: string): string {
return _HTTP_OP_GLYPHS[op as HTTPPollOp] ?? '=';
}
function _httpOpIconPath(op: string): string {
return _HTTP_OP_ICON_PATHS[op as HTTPPollOp] ?? P.check;
}
/** Destroy any EntitySelect / IconSelect widgets that the http_poll rule
* branch attached to *container*. Safe to call when there are none —
* it just clears the stash. Called both before re-rendering the rule's
* fields (rule-type change) and before removing the rule row entirely. */
function _disposeHTTPPollWidgets(container: HTMLElement): void {
const stash = (container as any)._httpPollWidgets;
if (!stash) return;
try { stash.vsEntitySelect?.destroy?.(); } catch { /* widget already gone */ }
try { stash.opIconSelect?.destroy?.(); } catch { /* widget already gone */ }
delete (container as any)._httpPollWidgets;
}
/** Render a chain-arrow separator span. `+` between AND-rules,
* the localised OR label between OR-rules, and `→` for the
* rule-chain → scene-activation transition. */
@@ -681,11 +749,12 @@ export function addAutomationRule() {
_autoGenerateAutomationName();
}
const RULE_TYPE_KEYS = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant'];
const RULE_TYPE_KEYS = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant', 'http_poll'];
const RULE_TYPE_ICONS = {
startup: P.power, application: P.smartphone,
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
mqtt: P.radio, webhook: P.globe, home_assistant: P.home,
http_poll: P.globe,
};
const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen'];
@@ -777,7 +846,7 @@ function addAutomationRuleRow(rule: any) {
<select class="rule-type-select">
${RULE_TYPE_KEYS.map(k => `<option value="${k}" ${ruleType === k ? 'selected' : ''}>${t('automations.rule.' + k)}</option>`).join('')}
</select>
<button type="button" class="btn-remove-rule" onclick="this.closest('.automation-rule-row').remove(); if(window._autoGenerateAutomationName) window._autoGenerateAutomationName();" title="Remove">${ICON_TRASH}</button>
<button type="button" class="btn-remove-rule" title="Remove">${ICON_TRASH}</button>
</div>
<div class="rule-fields-container" style="display:none"></div>
`;
@@ -794,14 +863,29 @@ function addAutomationRuleRow(rule: any) {
const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement;
const container = row.querySelector('.rule-fields-container') as HTMLElement;
// Remove button — dispose any widgets the rule body stashed (portal
// overlays would otherwise leak) before pulling the row from the DOM.
const removeBtn = row.querySelector('.btn-remove-rule') as HTMLButtonElement;
removeBtn.addEventListener('click', () => {
_disposeHTTPPollWidgets(container);
row.remove();
const autoGen = (window as any)._autoGenerateAutomationName;
if (typeof autoGen === 'function') autoGen();
});
// Attach IconSelect to the rule type dropdown
const ruleIconSelect = new IconSelect({
target: typeSelect,
items: _buildRuleTypeItems(),
columns: 4,
columns: 3,
} as any);
function renderFields(type: any, data: any) {
// Tear down any widgets the previous renderFields call attached
// (EntitySelect/IconSelect portal overlays to document.body, so
// a bare ``container.innerHTML = …`` leaves them in the registry).
_disposeHTTPPollWidgets(container);
if (type === 'startup') {
container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.startup.hint')}</small>`;
return;
@@ -880,6 +964,7 @@ function addAutomationRuleRow(rule: any) {
</select>
</div>
</div>`;
enhanceMiniSelects(container, 'select.rule-display-state');
return;
}
if (type === 'mqtt') {
@@ -905,6 +990,7 @@ function addAutomationRuleRow(rule: any) {
</select>
</div>
</div>`;
enhanceMiniSelects(container, 'select.rule-mqtt-match-mode');
return;
}
if (type === 'home_assistant') {
@@ -987,6 +1073,87 @@ function addAutomationRuleRow(rule: any) {
return;
}
if (type === 'http_poll') {
const vsId = data.value_source_id || '';
const operator = data.operator || 'equals';
const valueStr = data.value || '';
container.innerHTML = `
<div class="rule-fields">
<small class="rule-hint-desc">${t('automations.rule.http_poll.hint')}</small>
<div class="rule-field">
<label>${t('automations.rule.http_poll.value_source')}</label>
<select class="rule-http-value-source">
<option value="">—</option>
</select>
</div>
<div class="rule-field">
<label>${t('automations.rule.http_poll.operator')}</label>
<select class="rule-http-operator">
${HTTP_OP_KEYS.map(k => `<option value="${k}" ${operator === k ? 'selected' : ''}>${t('automations.rule.http_poll.operator.' + k)}</option>`).join('')}
</select>
</div>
<div class="rule-field rule-http-value-field">
<label>${t('automations.rule.http_poll.value')}</label>
<input type="text" class="rule-http-value" value="${escapeHtml(valueStr)}" placeholder="${escapeHtml(t('automations.rule.http_poll.value.placeholder'))}">
</div>
</div>`;
// Pull only HTTP value sources (source_type === 'http')
const httpVs = (_cachedValueSources || []).filter((v: any) => v.source_type === 'http');
// Wire EntitySelect for the value source picker
const vsSelect = container.querySelector('.rule-http-value-source') as HTMLSelectElement;
// Pre-populate the option so EntitySelect can sync display text.
vsSelect.innerHTML = `<option value="">—</option>` +
httpVs.map((v: any) => `<option value="${v.id}" ${v.id === vsId ? 'selected' : ''}>${escapeHtml(v.name)}</option>`).join('');
vsSelect.value = vsId || '';
const vsEntitySelect = new EntitySelect({
target: vsSelect,
getItems: () => (_cachedValueSources || [])
.filter((v: any) => v.source_type === 'http')
.map((v: any) => ({
value: v.id,
label: v.name,
icon: _icon(P.globe),
desc: v.json_path || t('automations.rule.http_poll.raw_body'),
})),
placeholder: t('palette.search'),
});
// Wire IconSelect for operator
const opSelect = container.querySelector('.rule-http-operator') as HTMLSelectElement;
const opItems = HTTP_OP_KEYS.map(k => ({
value: k,
icon: _icon(_httpOpIconPath(k)),
label: t('automations.rule.http_poll.operator.' + k),
desc: t('automations.rule.http_poll.operator.' + k + '.desc'),
}));
const opIconSelect = new IconSelect({
target: opSelect,
items: opItems,
columns: 3,
onChange: (newOp: string) => {
// Hide the value field when operator is 'exists'
const valField = container.querySelector('.rule-http-value-field') as HTMLElement;
if (valField) valField.style.display = newOp === 'exists' ? 'none' : '';
},
});
// Sync initial visibility based on the operator we just loaded.
const valField = container.querySelector('.rule-http-value-field') as HTMLElement;
if (valField) valField.style.display = operator === 'exists' ? 'none' : '';
// Stash both widgets so they can be destroyed when the row's
// rule type changes (renderFields re-entry) or the row is
// removed (button.btn-remove-rule onclick — calls
// _disposeHTTPPollWidgets via the row's data hook).
(container as any)._httpPollWidgets = {
vsEntitySelect,
opIconSelect,
};
return;
}
if (type === 'webhook') {
if (data.token) {
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
@@ -1102,6 +1269,18 @@ function getAutomationEditorRules() {
state: (row.querySelector('.rule-ha-state') as HTMLInputElement).value,
match_mode: (row.querySelector('.rule-ha-match-mode') as HTMLSelectElement).value || 'exact',
});
} else if (ruleType === 'http_poll') {
const op = (row.querySelector('.rule-http-operator') as HTMLSelectElement).value || 'equals';
const r: any = {
rule_type: 'http_poll',
value_source_id: (row.querySelector('.rule-http-value-source') as HTMLSelectElement).value,
operator: op,
};
// The 'exists' operator has no comparison value.
if (op !== 'exists') {
r.value = (row.querySelector('.rule-http-value') as HTMLInputElement).value;
}
rules.push(r);
} else {
const matchType = (row.querySelector('.rule-match-type') as HTMLSelectElement).value;
const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim();
@@ -1114,6 +1293,8 @@ function getAutomationEditorRules() {
export async function saveAutomationEditor() {
const idInput = document.getElementById('automation-editor-id') as HTMLInputElement;
if (automationModal.closeIfPristine(idInput.value)) return;
const nameInput = document.getElementById('automation-editor-name') as HTMLInputElement;
const enabledInput = document.getElementById('automation-editor-enabled') as HTMLInputElement;
const logicSelect = document.getElementById('automation-editor-logic') as HTMLSelectElement;
+44
View File
@@ -201,6 +201,49 @@ class HomeAssistantRule(Rule):
)
@dataclass
class HTTPPollRule(Rule):
"""Activate based on a value extracted by an HTTP value source.
The extraction (URL, auth, json_path, cadence) lives on an
``HTTPValueSource``; this rule just references the value source and
compares its current raw value to ``value`` using ``operator``.
"""
rule_type: str = "http_poll"
value_source_id: str = "" # references an HTTPValueSource
operator: str = "equals" # equals | not_equals | contains | regex | gt | lt | exists
value: str = ""
def to_dict(self) -> dict:
d = super().to_dict()
d["value_source_id"] = self.value_source_id
d["operator"] = self.operator
d["value"] = self.value
return d
@classmethod
def from_dict(cls, data: dict) -> "HTTPPollRule":
# Accept legacy ``http_source_id`` + ``json_path`` payloads from any
# in-flight v1 data and ignore them — the new shape only needs the
# value_source_id. Log a warning so a dev DB that still has the old
# form on disk is visible: such a row will load with
# ``value_source_id=""`` and the rule will silently evaluate to
# False forever otherwise.
if "http_source_id" in data or "json_path" in data:
logger.warning(
"Migrating legacy http_poll rule (had keys: %s). "
"value_source_id is empty; re-point the rule at an HTTPValueSource "
"and re-save to clear this warning.",
sorted(k for k in ("http_source_id", "json_path") if k in data),
)
return cls(
value_source_id=data.get("value_source_id", ""),
operator=data.get("operator", "equals"),
value=data.get("value", ""),
)
_RULE_MAP: Dict[str, Type[Rule]] = {
"application": ApplicationRule,
"time_of_day": TimeOfDayRule,
@@ -210,6 +253,7 @@ _RULE_MAP: Dict[str, Type[Rule]] = {
"webhook": WebhookRule,
"startup": StartupRule,
"home_assistant": HomeAssistantRule,
"http_poll": HTTPPollRule,
# Legacy: "always" maps to StartupRule for migration
"always": StartupRule,
}