Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/data/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
26 changes: 17 additions & 9 deletions src/data/automation_i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -320,15 +326,17 @@ const tryDescribeTrigger = (
}
if (to.length !== 0) {
toString = formatListWithOrs(hass.locale, to);
toChoice = "toUsed";
toChoice = toIsNeg ? "toNotUsed" : "toUsed";
}
}
}

if (
!trigger.attribute &&
trigger.from === undefined &&
trigger.to === undefined
trigger.not_from === undefined &&
trigger.to === undefined &&
trigger.not_to === undefined
) {
toChoice = "special";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -39,6 +47,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])),
})
);
Expand Down Expand Up @@ -128,6 +138,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: {
Expand All @@ -151,6 +182,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: {
Expand Down Expand Up @@ -210,14 +262,22 @@ 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,
};

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,
Expand All @@ -240,21 +300,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];
Expand All @@ -266,6 +332,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
Expand Down
6 changes: 5 additions & 1 deletion src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading