fix: Match state trigger/condition semantics to HA (#2565)

* Closes #2530
This commit is contained in:
Dermot Duffy
2026-07-04 10:23:51 -07:00
committed by GitHub
parent 3494706225
commit 9f956fe89f
15 changed files with 825 additions and 69 deletions
@@ -1,21 +1,25 @@
import { arrayify } from '../../../utils/basic';
import { haEqual } from '../../../ha/event-match';
import { arrayify, arrayifyWithFalsy } from '../../../utils/basic';
import { renderTimePeriodToSeconds } from '../../common/time-period';
import type { ConditionsEvaluationResult, ConditionState } from '../types';
import type { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './types';
// Resolve each expected value that names an entity present in `hass` to that
// entity's current state, accepting either the literal or the resolved value
// (i.e. HA's Lovelace state condition will resolve "state: input_boolean.foo"
// to "state: on" when input_boolean.foo is on).
const resolveExpectedStates = (
values: string | string[],
state?: ConditionState,
): string[] =>
// Cannot use `arrayify` as an empty-string expected value is a real value.
(Array.isArray(values) ? values : [values]).flatMap((value) => {
const resolved = state?.hass?.states?.[value]?.state;
return resolved !== undefined ? [value, resolved] : [value];
});
// Home Assistant resolves an expected value that names an `input_*` helper to
// that helper's current state (its Lovelace state-condition behaviour), on both
// the state and the attribute path; only these helper domains are resolved.
// Regexp directly from: https://github.com/home-assistant/core/blob/dev/homeassistant/helpers/condition.py
const INPUT_ENTITY_ID =
/^input_(?:select|text|number|boolean|datetime)\.(?!.+__)(?!_)[\da-z_]+(?<!_)$/;
const isInputHelperName = (value: unknown): value is string =>
typeof value === 'string' && INPUT_ENTITY_ID.test(value);
// Resolve an expected value: an `input_*` helper name becomes that helper's
// state (compared in its place, not the literal name); any other value is used
// as-is. A referenced helper is guaranteed present here -- missing ones stop the
// scan in `matchesExpected` before this is called.
const resolveExpectedValue = (expected: unknown, state?: ConditionState): unknown =>
isInputHelperName(expected) ? state?.hass?.states?.[expected]?.state : expected;
export class StateConditionEvaluator implements ConditionEvaluator {
private _condition: ConditionOfType<'state'>;
@@ -31,6 +35,7 @@ export class StateConditionEvaluator implements ConditionEvaluator {
oldState?: ConditionState,
): ConditionsEvaluationResult {
const condition = this._condition;
const attribute = condition.attribute;
// `entity` is canonical; `entity_id` is the accepted automation-dialect alias.
// Either may be a list; with multiple entities all must match (HA's `match: all`).
@@ -39,19 +44,44 @@ export class StateConditionEvaluator implements ConditionEvaluator {
return { result: false };
}
// The compared value is the attribute when `attribute` is set, else the state.
const readValue = (entityID: string, state?: ConditionState): string | null => {
// The state (a string) or, when `attribute` is set, the raw attribute value
// (any type, including a present `null`). Returns `undefined` when the
// entity is missing or the attribute key is absent, which HA treats as no
// match -- distinct from a present `null` value (`0`/`false`/`''` are also
// real). HA attributes arrive as JSON, so a present value is never
// `undefined`.
const readValue = (entityID: string, state?: ConditionState): unknown => {
const stateObj = state?.hass?.states?.[entityID];
if (!stateObj) {
return null;
return undefined;
}
if (condition.attribute) {
const value = stateObj.attributes?.[condition.attribute];
return value === undefined || value === null ? null : String(value);
if (attribute !== undefined) {
// Own-property check (not `in`) so inherited props like `toString` are
// not mistaken for attributes, matching Python dict membership.
return Object.prototype.hasOwnProperty.call(stateObj.attributes, attribute)
? stateObj.attributes[attribute]
: undefined;
}
return stateObj.state;
};
// Whether `value` matches one of the configured expected values, scanned in
// order with HA's Python `==` semantics (so `50` equals `50`, `true` equals
// `1`, and `50` does not equal `"50"`). An `input_*` helper name is matched
// by its state; HA stops and fails at a referenced helper that is
// unavailable, so the scan stops there rather than trying later values.
const matchesExpected = (expected: unknown, value: unknown): boolean => {
for (const v of arrayifyWithFalsy(expected)) {
if (isInputHelperName(v) && !newState?.hass?.states?.[v]) {
return false;
}
if (haEqual(value, resolveExpectedValue(v, newState))) {
return true;
}
}
return false;
};
const matchesEntity = (entityID: string): boolean => {
const fromValue = readValue(entityID, oldState);
const toValue = readValue(entityID, newState);
@@ -59,22 +89,21 @@ export class StateConditionEvaluator implements ConditionEvaluator {
let result: boolean;
if (condition.state === undefined && condition.state_not === undefined) {
// With neither `state` nor `state_not`, match any change of value.
result = toValue !== fromValue;
} else if (toValue === null) {
// A missing entity or attribute cannot match; an empty-string state is
// a real value, handled in the comparison below.
result = !haEqual(toValue, fromValue);
} else if (toValue === undefined) {
// A missing entity or attribute cannot match. A present value (including
// `null` or `''`) is a real value, handled in the comparison below.
result = false;
} else {
result =
(condition.state === undefined ||
resolveExpectedStates(condition.state, newState).includes(toValue)) &&
(condition.state === undefined || matchesExpected(condition.state, toValue)) &&
(condition.state_not === undefined ||
!resolveExpectedStates(condition.state_not, newState).includes(toValue));
!matchesExpected(condition.state_not, toValue));
}
// `for`: the match must have been held for at least the given duration.
// `for`: the match must have been held for longer than the given duration.
// Evaluated against `last_changed` at evaluation time (correct for the
// point-in-time / ongoing-condition use).
// point-in-time / ongoing-condition use). HA compares strictly (`>`).
if (result && condition.for !== undefined) {
const forSeconds = renderTimePeriodToSeconds(
this._context.templateRenderer,
@@ -87,7 +116,7 @@ export class StateConditionEvaluator implements ConditionEvaluator {
} else {
const heldSeconds =
(new Date().getTime() - new Date(lastChanged).getTime()) / 1000;
result = heldSeconds >= forSeconds;
result = heldSeconds > forSeconds;
}
}
return result;
@@ -1,6 +1,7 @@
import type { HassEntity } from 'home-assistant-js-websocket';
import { arrayify } from '../../../utils/basic';
import { haEqual } from '../../../ha/event-match';
import { arrayifyWithFalsy } from '../../../utils/basic';
import { EntityStateTriggerBase } from './entity-state-base';
import type { TriggerOfType } from './types';
@@ -30,32 +31,42 @@ export class StateTrigger extends EntityStateTriggerBase<TriggerOfType<'state'>>
);
}
private _readValue(stateObj?: HassEntity): string | null {
// The state (a string) or, when `attribute` is set, the raw attribute value
// (any type). `null` is the "no value" sentinel for a missing entity or
// attribute; a genuine attribute value of `0`/`false`/`''` is a real value.
private _readValue(stateObj?: HassEntity): unknown {
if (!stateObj) {
return null;
}
const attribute = this._trigger.attribute;
if (attribute !== undefined) {
const value = stateObj.attributes?.[attribute];
return value === undefined || value === null ? null : String(value);
// Own-property check (not `?.[]`) so inherited props like `toString` are
// not mistaken for attributes; HA collapses a missing key to `None`.
const attributes = stateObj.attributes;
return Object.prototype.hasOwnProperty.call(attributes, attribute)
? attributes[attribute]
: null;
}
return stateObj.state;
}
// Whether `value` is one of a constraint's values (a single value or a list),
// compared with HA's Python `==` (`haEqual`; for a string state this is plain
// equality). Falsy values (`0`/`false`/`''`) are kept as real values.
private _includes(constraint: unknown, value: unknown): boolean {
return arrayifyWithFalsy(constraint).some((v) => haEqual(v, value));
}
// A value matches when it is in the positive set (`from`/`to`), or not in the
// negative set (`not_from`/`not_to`); an absent or `null` constraint matches
// anything.
private _matches(
value: string | null,
positive?: string | string[] | null,
negative?: string | string[] | null,
): boolean {
// anything. A `null` value (missing entity/attribute, HA's `None`) is itself a
// real value: it matches only a constraint whose set contains `null`.
private _matches(value: unknown, positive?: unknown, negative?: unknown): boolean {
if (positive !== undefined && positive !== null) {
return value !== null && arrayify(positive).includes(value);
return this._includes(positive, value);
}
if (negative !== undefined && negative !== null) {
// An absent value (entity missing) is not in the set, so it matches.
return !(value !== null && arrayify(negative).includes(value));
return !this._includes(negative, value);
}
return true;
}
@@ -70,7 +81,7 @@ export class StateTrigger extends EntityStateTriggerBase<TriggerOfType<'state'>>
const newValue = this._readValue(newStateObj);
// When watching an attribute, ignore changes that don't move it.
if (trigger.attribute !== undefined && oldValue === newValue) {
if (trigger.attribute !== undefined && haEqual(oldValue, newValue)) {
return;
}
@@ -81,13 +92,13 @@ export class StateTrigger extends EntityStateTriggerBase<TriggerOfType<'state'>>
// from/to test the values but not that they *differ*, so an attribute-only
// event (value unchanged) can still satisfy them. Require a genuine change
// when a constraint is set; with none, trigger on those too.
!(hasStateConstraint && oldValue === newValue);
!(hasStateConstraint && haEqual(oldValue, newValue));
if (!matches) {
// Only a real change of the watched value cancels a pending `for:` hold;
// an attribute-only change (value unchanged) must leave it running, just
// as HA's `for:` keys off the state, not the whole state object.
if (oldValue !== newValue) {
if (!haEqual(oldValue, newValue)) {
this._cancelForTimer(entityID);
}
return;