From 8cfa5690a0c5d843342d356e620e660f8b78932a Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Wed, 29 Oct 2025 19:15:11 +0100 Subject: [PATCH 1/2] Automation editor: support negated trigger --- src/data/automation.ts | 2 + src/data/automation_i18n.ts | 26 ++-- .../types/ha-automation-trigger-state.ts | 129 ++++++++++++++++-- src/translations/en.json | 6 +- 4 files changed, 141 insertions(+), 22 deletions(-) diff --git a/src/data/automation.ts b/src/data/automation.ts index e0eca6cc581b..725bd82e641c 100644 --- a/src/data/automation.ts +++ b/src/data/automation.ts @@ -92,7 +92,9 @@ export interface StateTrigger extends BaseTrigger { entity_id: string | string[]; attribute?: string; from?: string | string[]; + not_from?: string | string[] | null; to?: string | string[]; + not_to?: string | string[] | null; for?: string | number | ForDict; } diff --git a/src/data/automation_i18n.ts b/src/data/automation_i18n.ts index 55289b38c7be..e265416cda77 100644 --- a/src/data/automation_i18n.ts +++ b/src/data/automation_i18n.ts @@ -259,14 +259,17 @@ const tryDescribeTrigger = ( let fromChoice = "other"; let fromString = ""; - if (trigger.from !== undefined) { + const fromIsNeg = trigger.not_from !== undefined; + const fromValue = fromIsNeg ? trigger.not_from : trigger.from; + if (fromValue !== undefined) { let fromArray: string[] = []; - if (trigger.from === null) { + if (fromValue === null) { if (!trigger.attribute) { + // Note: no separate translation for negative null; fallback to "any state" fromChoice = "null"; } } else { - fromArray = ensureArray(trigger.from); + fromArray = ensureArray(fromValue); const from: string[] = []; for (const state of fromArray) { @@ -286,21 +289,24 @@ const tryDescribeTrigger = ( } if (from.length !== 0) { fromString = formatListWithOrs(hass.locale, from); - fromChoice = "fromUsed"; + fromChoice = fromIsNeg ? "fromNotUsed" : "fromUsed"; } } } let toChoice = "other"; let toString = ""; - if (trigger.to !== undefined) { + const toIsNeg = trigger.not_to !== undefined; + const toValue = toIsNeg ? trigger.not_to : trigger.to; + if (toValue !== undefined) { let toArray: string[] = []; - if (trigger.to === null) { + if (toValue === null) { if (!trigger.attribute) { + // Note: no separate translation for negative null; fallback to "any state" toChoice = "null"; } } else { - toArray = ensureArray(trigger.to); + toArray = ensureArray(toValue); const to: string[] = []; for (const state of toArray) { @@ -320,7 +326,7 @@ const tryDescribeTrigger = ( } if (to.length !== 0) { toString = formatListWithOrs(hass.locale, to); - toChoice = "toUsed"; + toChoice = toIsNeg ? "toNotUsed" : "toUsed"; } } } @@ -328,7 +334,9 @@ const tryDescribeTrigger = ( if ( !trigger.attribute && trigger.from === undefined && - trigger.to === undefined + trigger.not_from === undefined && + trigger.to === undefined && + trigger.not_to === undefined ) { toChoice = "special"; } diff --git a/src/panels/config/automation/trigger/types/ha-automation-trigger-state.ts b/src/panels/config/automation/trigger/types/ha-automation-trigger-state.ts index 5fc0896d4fc1..837eda9d212d 100644 --- a/src/panels/config/automation/trigger/types/ha-automation-trigger-state.ts +++ b/src/panels/config/automation/trigger/types/ha-automation-trigger-state.ts @@ -39,6 +39,8 @@ const stateTriggerStruct = assign( attribute: optional(string()), from: optional(union([nullable(string()), array(string())])), to: optional(union([nullable(string()), array(string())])), + not_from: optional(union([nullable(string()), array(string())])), + not_to: optional(union([nullable(string()), array(string())])), for: optional(union([number(), string(), forDictStruct])), }) ); @@ -128,6 +130,27 @@ export class HaStateTrigger extends LitElement implements TriggerElement { }, }, }, + { + name: "from_match", + selector: { + button_toggle: { + options: [ + { + value: "is", + label: localize( + "ui.panel.config.automation.editor.triggers.type.state.is" + ), + }, + { + value: "is_not", + label: localize( + "ui.panel.config.automation.editor.triggers.type.state.is_not" + ), + }, + ], + }, + }, + }, { name: "from", context: { @@ -151,6 +174,27 @@ export class HaStateTrigger extends LitElement implements TriggerElement { }, }, }, + { + name: "to_match", + selector: { + button_toggle: { + options: [ + { + value: "is", + label: localize( + "ui.panel.config.automation.editor.triggers.type.state.is" + ), + }, + { + value: "is_not", + label: localize( + "ui.panel.config.automation.editor.triggers.type.state.is_not" + ), + }, + ], + }, + }, + }, { name: "to", context: { @@ -216,8 +260,16 @@ export class HaStateTrigger extends LitElement implements TriggerElement { for: trgFor, }; - data.to = this._normalizeStates(this.trigger.to, data.attribute); - data.from = this._normalizeStates(this.trigger.from, data.attribute); + const hasNotFrom = this.trigger.not_from !== undefined; + const hasNotTo = this.trigger.not_to !== undefined; + data.from_match = hasNotFrom ? "is_not" : "is"; + data.to_match = hasNotTo ? "is_not" : "is"; + + const fromSource = hasNotFrom ? this.trigger.not_from : this.trigger.from; + const toSource = hasNotTo ? this.trigger.not_to : this.trigger.to; + + data.from = this._normalizeStates(fromSource, data.attribute); + data.to = this._normalizeStates(toSource, data.attribute); const schema = this._schema( this.hass.localize, this.trigger.attribute, @@ -240,21 +292,27 @@ export class HaStateTrigger extends LitElement implements TriggerElement { private _valueChanged(ev: CustomEvent): void { ev.stopPropagation(); const newTrigger = ev.detail.value; + const fromMatch = newTrigger.from_match === "is_not" ? "is_not" : "is"; + const toMatch = newTrigger.to_match === "is_not" ? "is_not" : "is"; - newTrigger.to = this._applyAnyStateExclusive( - newTrigger.to, + // Sanitize values based on match mode + const sanitizedFrom = this._sanitizeForMatch( + newTrigger.from, + fromMatch, newTrigger.attribute ); - if (Array.isArray(newTrigger.to) && newTrigger.to.length === 0) { - delete newTrigger.to; - } - newTrigger.from = this._applyAnyStateExclusive( - newTrigger.from, + const sanitizedTo = this._sanitizeForMatch( + newTrigger.to, + toMatch, newTrigger.attribute ); - if (Array.isArray(newTrigger.from) && newTrigger.from.length === 0) { - delete newTrigger.from; - } + + // Apply back to correct keys and clean up UI-only props + delete newTrigger.from_match; + delete newTrigger.to_match; + + this._applyMatchAssignment(newTrigger, "from", fromMatch, sanitizedFrom); + this._applyMatchAssignment(newTrigger, "to", toMatch, sanitizedTo); Object.keys(newTrigger).forEach((key) => { const val = newTrigger[key]; @@ -266,6 +324,53 @@ export class HaStateTrigger extends LitElement implements TriggerElement { fireEvent(this, "value-changed", { value: newTrigger }); } + private _applyMatchAssignment( + target: any, + baseKey: "from" | "to", + match: "is" | "is_not", + value: string | string[] | null | undefined + ): void { + const negKey = `not_${baseKey}`; + + const hasValue = !( + value === undefined || + (Array.isArray(value) && value.length === 0) + ); + + const setKey = match === "is_not" ? negKey : baseKey; + const clearKey = match === "is_not" ? baseKey : negKey; + + // Always clear the opposite key first, then set (or clear) the target key + delete target[clearKey]; + if (hasValue) { + target[setKey] = value; + } else { + delete target[setKey]; + } + } + + private _sanitizeForMatch( + val: string | string[] | null | undefined, + match: string, + attribute?: string + ): string | string[] | null | undefined { + if (match === "is") { + return this._applyAnyStateExclusive(val, attribute); + } + // is_not mode: if Any state selected and no attribute, map to null. + if (Array.isArray(val)) { + if (val.includes(ANY_STATE_VALUE)) { + return attribute ? undefined : null; + } + const filtered = val.filter((v) => v !== ANY_STATE_VALUE); + return filtered.length > 0 ? filtered : undefined; + } + if (val === ANY_STATE_VALUE) { + return attribute ? undefined : null; + } + return val ?? undefined; + } + private _applyAnyStateExclusive( val: string | string[] | null | undefined, attribute?: string diff --git a/src/translations/en.json b/src/translations/en.json index c551a1a59e07..31776c69f3ae 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4027,13 +4027,17 @@ "state": { "label": "State", "attribute": "Attribute (optional)", + "from_match": "From match", "from": "From (optional)", "for": "For", + "to_match": "To match", "to": "To (optional)", + "is": "is", + "is_not": "is not", "any_state_ignore_attributes": "Any state (ignoring attribute changes)", "description": { "picker": "When the state of an entity (or attribute) changes.", - "full": "When{hasAttribute, select, \n true { {attribute} of} \n other {}\n} {hasEntity, select, \n true {{entity}} \n other {something}\n} changes{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n special { state or any attributes} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}" + "full": "When{hasAttribute, select, \n true { {attribute} of} \n other {}\n} {hasEntity, select, \n true {{entity}} \n other {something}\n} changes{fromChoice, select, \n fromUsed { from {fromString}}\n fromNotUsed { from any state except {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n toNotUsed { to any state except {toString}}\n null { to any state} \n special { state or any attributes} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}" } }, "homeassistant": { From 7ed7926a6dfcd64fb7358a8c28bb21360bdb6faf Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Wed, 29 Oct 2025 21:25:07 +0100 Subject: [PATCH 2/2] Ugly fix for typescript --- .../trigger/types/ha-automation-trigger-state.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/panels/config/automation/trigger/types/ha-automation-trigger-state.ts b/src/panels/config/automation/trigger/types/ha-automation-trigger-state.ts index 837eda9d212d..706622c143c0 100644 --- a/src/panels/config/automation/trigger/types/ha-automation-trigger-state.ts +++ b/src/panels/config/automation/trigger/types/ha-automation-trigger-state.ts @@ -30,6 +30,14 @@ import type { SchemaUnion, } from "../../../../../components/ha-form/types"; +// Extended form data used only in the UI form. These fields do not exist on the +// persisted StateTrigger type, but are convenient for toggling between positive +// and negative matches in the editor. +type StateTriggerFormData = StateTrigger & { + from_match?: "is" | "is_not"; + to_match?: "is" | "is_not"; +}; + const stateTriggerStruct = assign( baseTriggerStruct, object({ @@ -254,7 +262,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement { protected render() { const trgFor = createDurationData(this.trigger.for); - const data = { + const data: StateTriggerFormData = { ...this.trigger, entity_id: ensureArray(this.trigger.entity_id), for: trgFor,