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
+10
View File
@@ -500,6 +500,16 @@ Matches a Home Assistant entity's state. Unlike most types, the **condition** an
Both forms accept `entity` (or its `entity_id` alias) as a single entity or a Both forms accept `entity` (or its `entity_id` alias) as a single entity or a
list. list.
Typing exactly mirrors Home Assistant behavior, but may be surprising:
- When `attribute` is set, the match values (`state` / `state_not` for a
condition; `from` / `to` / `not_from` / `not_to` for a trigger) are compared
against the raw attribute value using Home Assistant's equality rules, so they
may be any type and _must_ be written with that type: `state: 50` matches a
numeric attribute of `50`, whereas `state: '50'` (a string) does not.
Additionally in this case, Home Assistant treats `true` as equalling `1`.
- Without `attribute`, matching is strictly string-vs-string.
### As a condition ### As a condition
```yaml ```yaml
@@ -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 { renderTimePeriodToSeconds } from '../../common/time-period';
import type { ConditionsEvaluationResult, ConditionState } from '../types'; import type { ConditionsEvaluationResult, ConditionState } from '../types';
import type { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './types'; import type { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './types';
// Resolve each expected value that names an entity present in `hass` to that // Home Assistant resolves an expected value that names an `input_*` helper to
// entity's current state, accepting either the literal or the resolved value // that helper's current state (its Lovelace state-condition behaviour), on both
// (i.e. HA's Lovelace state condition will resolve "state: input_boolean.foo" // the state and the attribute path; only these helper domains are resolved.
// to "state: on" when input_boolean.foo is on). // Regexp directly from: https://github.com/home-assistant/core/blob/dev/homeassistant/helpers/condition.py
const resolveExpectedStates = ( const INPUT_ENTITY_ID =
values: string | string[], /^input_(?:select|text|number|boolean|datetime)\.(?!.+__)(?!_)[\da-z_]+(?<!_)$/;
state?: ConditionState,
): string[] => const isInputHelperName = (value: unknown): value is string =>
// Cannot use `arrayify` as an empty-string expected value is a real value. typeof value === 'string' && INPUT_ENTITY_ID.test(value);
(Array.isArray(values) ? values : [values]).flatMap((value) => {
const resolved = state?.hass?.states?.[value]?.state; // Resolve an expected value: an `input_*` helper name becomes that helper's
return resolved !== undefined ? [value, resolved] : [value]; // 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 { export class StateConditionEvaluator implements ConditionEvaluator {
private _condition: ConditionOfType<'state'>; private _condition: ConditionOfType<'state'>;
@@ -31,6 +35,7 @@ export class StateConditionEvaluator implements ConditionEvaluator {
oldState?: ConditionState, oldState?: ConditionState,
): ConditionsEvaluationResult { ): ConditionsEvaluationResult {
const condition = this._condition; const condition = this._condition;
const attribute = condition.attribute;
// `entity` is canonical; `entity_id` is the accepted automation-dialect alias. // `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`). // 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 }; return { result: false };
} }
// The compared value is the attribute when `attribute` is set, else the state. // The state (a string) or, when `attribute` is set, the raw attribute value
const readValue = (entityID: string, state?: ConditionState): string | null => { // (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]; const stateObj = state?.hass?.states?.[entityID];
if (!stateObj) { if (!stateObj) {
return null; return undefined;
} }
if (condition.attribute) { if (attribute !== undefined) {
const value = stateObj.attributes?.[condition.attribute]; // Own-property check (not `in`) so inherited props like `toString` are
return value === undefined || value === null ? null : String(value); // not mistaken for attributes, matching Python dict membership.
return Object.prototype.hasOwnProperty.call(stateObj.attributes, attribute)
? stateObj.attributes[attribute]
: undefined;
} }
return stateObj.state; 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 matchesEntity = (entityID: string): boolean => {
const fromValue = readValue(entityID, oldState); const fromValue = readValue(entityID, oldState);
const toValue = readValue(entityID, newState); const toValue = readValue(entityID, newState);
@@ -59,22 +89,21 @@ export class StateConditionEvaluator implements ConditionEvaluator {
let result: boolean; let result: boolean;
if (condition.state === undefined && condition.state_not === undefined) { if (condition.state === undefined && condition.state_not === undefined) {
// With neither `state` nor `state_not`, match any change of value. // With neither `state` nor `state_not`, match any change of value.
result = toValue !== fromValue; result = !haEqual(toValue, fromValue);
} else if (toValue === null) { } else if (toValue === undefined) {
// A missing entity or attribute cannot match; an empty-string state is // A missing entity or attribute cannot match. A present value (including
// a real value, handled in the comparison below. // `null` or `''`) is a real value, handled in the comparison below.
result = false; result = false;
} else { } else {
result = result =
(condition.state === undefined || (condition.state === undefined || matchesExpected(condition.state, toValue)) &&
resolveExpectedStates(condition.state, newState).includes(toValue)) &&
(condition.state_not === undefined || (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 // 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) { if (result && condition.for !== undefined) {
const forSeconds = renderTimePeriodToSeconds( const forSeconds = renderTimePeriodToSeconds(
this._context.templateRenderer, this._context.templateRenderer,
@@ -87,7 +116,7 @@ export class StateConditionEvaluator implements ConditionEvaluator {
} else { } else {
const heldSeconds = const heldSeconds =
(new Date().getTime() - new Date(lastChanged).getTime()) / 1000; (new Date().getTime() - new Date(lastChanged).getTime()) / 1000;
result = heldSeconds >= forSeconds; result = heldSeconds > forSeconds;
} }
} }
return result; return result;
@@ -1,6 +1,7 @@
import type { HassEntity } from 'home-assistant-js-websocket'; 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 { EntityStateTriggerBase } from './entity-state-base';
import type { TriggerOfType } from './types'; 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) { if (!stateObj) {
return null; return null;
} }
const attribute = this._trigger.attribute; const attribute = this._trigger.attribute;
if (attribute !== undefined) { if (attribute !== undefined) {
const value = stateObj.attributes?.[attribute]; // Own-property check (not `?.[]`) so inherited props like `toString` are
return value === undefined || value === null ? null : String(value); // 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; 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 // 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 // negative set (`not_from`/`not_to`); an absent or `null` constraint matches
// anything. // anything. A `null` value (missing entity/attribute, HA's `None`) is itself a
private _matches( // real value: it matches only a constraint whose set contains `null`.
value: string | null, private _matches(value: unknown, positive?: unknown, negative?: unknown): boolean {
positive?: string | string[] | null,
negative?: string | string[] | null,
): boolean {
if (positive !== undefined && positive !== null) { if (positive !== undefined && positive !== null) {
return value !== null && arrayify(positive).includes(value); return this._includes(positive, value);
} }
if (negative !== undefined && negative !== null) { if (negative !== undefined && negative !== null) {
// An absent value (entity missing) is not in the set, so it matches. return !this._includes(negative, value);
return !(value !== null && arrayify(negative).includes(value));
} }
return true; return true;
} }
@@ -70,7 +81,7 @@ export class StateTrigger extends EntityStateTriggerBase<TriggerOfType<'state'>>
const newValue = this._readValue(newStateObj); const newValue = this._readValue(newStateObj);
// When watching an attribute, ignore changes that don't move it. // 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; 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 // 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 // event (value unchanged) can still satisfy them. Require a genuine change
// when a constraint is set; with none, trigger on those too. // when a constraint is set; with none, trigger on those too.
!(hasStateConstraint && oldValue === newValue); !(hasStateConstraint && haEqual(oldValue, newValue));
if (!matches) { if (!matches) {
// Only a real change of the watched value cancels a pending `for:` hold; // Only a real change of the watched value cancels a pending `for:` hold;
// an attribute-only change (value unchanged) must leave it running, just // an attribute-only change (value unchanged) must leave it running, just
// as HA's `for:` keys off the state, not the whole state object. // as HA's `for:` keys off the state, not the whole state object.
if (oldValue !== newValue) { if (!haEqual(oldValue, newValue)) {
this._cancelForTimer(entityID); this._cancelForTimer(entityID);
} }
return; return;
@@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { forwardIssues } from '../../../../utils/zod/forward-issues';
import { stringOrArray } from '../../common/string-or-array';
import { timePeriodSchema } from '../../common/time-period'; import { timePeriodSchema } from '../../common/time-period';
// Fields shared by the `state` condition AND trigger. // Fields shared by the `state` condition AND trigger.
@@ -10,3 +12,29 @@ export const stateBaseSchema = z.object({
// the match must hold for at least this time period. // the match must hold for at least this time period.
for: timePeriodSchema.optional(), for: timePeriodSchema.optional(),
}); });
// A state-match field (`from`/`to`/`state`/`state_not`/...). It accepts any
// JSON value because, when `attribute` is set, Home Assistant compares the raw
// attribute value against the configured value with Python `==` (any type is
// valid). When `attribute` is unset the value is restricted back to a string or
// list of strings by `checkStateMatchField`.
export const stateMatchValueSchema = z.unknown().optional();
// When `attribute` is unset, Home Assistant keeps a state-match field
// restricted to a string or list of strings; the widened
// `stateMatchValueSchema` skips that check, so re-apply the original schema
// here. `nullable` covers the trigger's `null` "match any" sentinel; conditions
// pass `false`.
export const checkStateMatchField = (
ctx: z.RefinementCtx,
field: string,
value: unknown,
{ nullable }: { nullable: boolean },
): void => {
if (value === undefined) {
return;
}
forwardIssues(ctx, value, nullable ? stringOrArray.nullable() : stringOrArray, [
field,
]);
};
@@ -1,7 +1,10 @@
import { z } from 'zod'; import { z } from 'zod';
import { stringOrArray } from '../../../common/string-or-array'; import {
import { stateBaseSchema } from '../../common/state'; checkStateMatchField,
stateBaseSchema,
stateMatchValueSchema,
} from '../../common/state';
import { conditionBaseSchema } from '../base'; import { conditionBaseSchema } from '../base';
import { entityConditionBaseSchema } from './entity-base'; import { entityConditionBaseSchema } from './entity-base';
@@ -13,13 +16,16 @@ export const stateConditionSchema = entityConditionBaseSchema
// If `condition` is omitted a state condition is assumed (picture-elements form). // If `condition` is omitted a state condition is assumed (picture-elements form).
condition: z.literal('state').optional(), condition: z.literal('state').optional(),
// Common to both of Home Assistant's condition dialects: // Without `attribute` these are string/list state matchers (enforced by the
state: stringOrArray.optional(), // `superRefine` below); with `attribute` they compare raw against the
// attribute value, so any type is accepted.
// Only present in HA picture elements dialect (not automation dialect), but //
// respected in both usecases in this card. // `state` is common to both of Home Assistant's condition dialects;
// `state_not` is only present in HA's picture-elements dialect (not the
// automation dialect), but respected in both usecases in this card.
// https://www.home-assistant.io/dashboards/picture-elements/#conditional-element // https://www.home-assistant.io/dashboards/picture-elements/#conditional-element
state_not: stringOrArray.optional(), state: stateMatchValueSchema,
state_not: stateMatchValueSchema,
// How a list of entities is combined: `all` (the default) requires every // How a list of entities is combined: `all` (the default) requires every
// entity to match, `any` requires at least one. // entity to match, `any` requires at least one.
@@ -31,4 +37,12 @@ export const stateConditionSchema = entityConditionBaseSchema
.refine( .refine(
(data) => data.state !== undefined || data.state_not !== undefined, (data) => data.state !== undefined || data.state_not !== undefined,
'A `state` condition requires `state` or `state_not`', 'A `state` condition requires `state` or `state_not`',
); )
// Without `attribute`, the match fields keep HA's string/list form.
.superRefine((data, ctx) => {
if (data.attribute !== undefined) {
return;
}
checkStateMatchField(ctx, 'state', data.state, { nullable: false });
checkStateMatchField(ctx, 'state_not', data.state_not, { nullable: false });
});
@@ -1,7 +1,10 @@
import { z } from 'zod'; import { z } from 'zod';
import { stringOrArray } from '../../../common/string-or-array'; import {
import { stateBaseSchema } from '../../common/state'; checkStateMatchField,
stateBaseSchema,
stateMatchValueSchema,
} from '../../common/state';
import { triggerBaseSchema } from '../base'; import { triggerBaseSchema } from '../base';
import { entityTriggerBaseSchema } from './entity-base'; import { entityTriggerBaseSchema } from './entity-base';
@@ -12,14 +15,16 @@ export const stateTriggerSchema = entityTriggerBaseSchema
.extend({ .extend({
trigger: z.literal('state'), trigger: z.literal('state'),
// HA accepts `null` here, distinct from omitting the key: `null` matches // Without `attribute` these are string/list state matchers (enforced by the
// any state value, but specifying it (vs. omitting all of from/to/not_*) // `superRefine` below); with `attribute` they compare raw against the
// restricts firing to real state changes rather than potentially // attribute value, so any type is accepted. HA also accepts `null` here,
// attribute-only changes. // distinct from omitting the key: `null` matches any state value, but
from: stringOrArray.nullable().optional(), // specifying it (vs. omitting all of from/to/not_*) restricts firing to real
to: stringOrArray.nullable().optional(), // state changes rather than potentially attribute-only changes.
not_from: stringOrArray.nullable().optional(), from: stateMatchValueSchema,
not_to: stringOrArray.nullable().optional(), to: stateMatchValueSchema,
not_from: stateMatchValueSchema,
not_to: stateMatchValueSchema,
}) })
// HA makes `from`/`not_from` and `to`/`not_to` mutually exclusive (vol.Exclusive). // HA makes `from`/`not_from` and `to`/`not_to` mutually exclusive (vol.Exclusive).
.refine( .refine(
@@ -29,4 +34,14 @@ export const stateTriggerSchema = entityTriggerBaseSchema
.refine( .refine(
(data) => !(data.to !== undefined && data.not_to !== undefined), (data) => !(data.to !== undefined && data.not_to !== undefined),
'`to` and `not_to` are mutually exclusive', '`to` and `not_to` are mutually exclusive',
); )
// Without `attribute`, the match fields keep HA's string/list form.
.superRefine((data, ctx) => {
if (data.attribute !== undefined) {
return;
}
checkStateMatchField(ctx, 'from', data.from, { nullable: true });
checkStateMatchField(ctx, 'to', data.to, { nullable: true });
checkStateMatchField(ctx, 'not_from', data.not_from, { nullable: true });
checkStateMatchField(ctx, 'not_to', data.not_to, { nullable: true });
});
+1 -1
View File
@@ -13,7 +13,7 @@ const isDict = (value: unknown): value is Record<string, unknown> =>
// that Python's `bool` is a subtype of `int`, so `true`/`false` equal `1`/`0` // that Python's `bool` is a subtype of `int`, so `true`/`false` equal `1`/`0`
// (and that equivalence propagates through nested lists/dicts). HA relies on // (and that equivalence propagates through nested lists/dicts). HA relies on
// it, so we must too for byte-for-byte parity. // it, so we must too for byte-for-byte parity.
const haEqual = (a: unknown, b: unknown): boolean => export const haEqual = (a: unknown, b: unknown): boolean =>
isEqualWith(a, b, (x, y) => { isEqualWith(a, b, (x, y) => {
if (typeof x === 'boolean' && typeof y === 'number') { if (typeof x === 'boolean' && typeof y === 'number') {
return Number(x) === y; return Number(x) === y;
+14 -1
View File
@@ -42,7 +42,10 @@ export function arrayMove(target: unknown[], from: number, to: number): unknown[
} }
/** /**
* Convert a value to an array if it is not already one. * Convert a value to an array if it is not already one, dropping falsy inputs
* (`undefined`/`null`/`0`/`false`/`''`) to an empty array. Use when an absent or
* empty value should become `[]`; use `arrayifyWithFalsy` when falsy values are
* significant and must be preserved.
* @param value: A value (which may be an array). * @param value: A value (which may be an array).
* @returns An array. * @returns An array.
*/ */
@@ -50,6 +53,16 @@ export const arrayify = <T>(value?: T | T[]): T[] => {
return value ? (Array.isArray(value) ? value : [value]) : []; return value ? (Array.isArray(value) ? value : [value]) : [];
}; };
/**
* Wrap a value in an array if it is not already one, preserving the value --
* including falsy ones like `0`, `false`, `''` and `null`. Contrast with
* `arrayify`, which instead drops all falsy inputs to `[]`.
* @param value A value (which may be an array).
* @returns An array.
*/
export const arrayifyWithFalsy = <T>(value: T | T[]): T[] =>
Array.isArray(value) ? value : [value];
/** /**
* Convert a value to an set if it is not already one. * Convert a value to an set if it is not already one.
* @param value: A value (which may be a set, an array or a T) * @param value: A value (which may be a set, an array or a T)
+27
View File
@@ -0,0 +1,27 @@
import type { z } from 'zod';
/**
* Validate `value` against `schema` and copy any issues it produces into a
* refinement context, prefixing each issue's path with `path`. Use inside a
* `.superRefine` to delegate a value to another schema while preserving Zod's
* own error messages -- e.g. when the outer schema widened a field (to
* `z.unknown()`) and needs to re-apply the original, narrower schema
* conditionally. Adds nothing when `value` satisfies `schema`.
* @param ctx The refinement context to add issues to.
* @param value The value to validate.
* @param schema The schema to validate `value` against.
* @param path A path prefix prepended to each forwarded issue's path.
*/
export const forwardIssues = (
ctx: z.RefinementCtx,
value: unknown,
schema: z.ZodType,
path: readonly PropertyKey[] = [],
): void => {
const result = schema.safeParse(value);
if (!result.success) {
for (const issue of result.error.issues) {
ctx.addIssue({ ...issue, path: [...path, ...issue.path] });
}
}
};
@@ -295,6 +295,245 @@ describe('state condition', () => {
).toBeFalsy(); ).toBeFalsy();
}); });
describe('attribute matching by type', () => {
const evaluateBattery = (state: unknown, batteryLevel: unknown): boolean =>
!!createConditionEvaluator(
{
condition: 'state' as const,
entity_id: 'sensor.battery',
attribute: 'battery_level',
// `state` is compared raw against the attribute (Home Assistant's
// `match_all` semantics), so it may be any type.
state,
},
createEvaluatorContext(),
).evaluate({
hass: createHASS({
'sensor.battery': createStateEntity({
attributes: { battery_level: batteryLevel },
}),
}),
}).result;
it('should match a numeric attribute against an unquoted number', () => {
expect(evaluateBattery(50, 50)).toBe(true);
});
it('should not match a numeric attribute against a stringified number', () => {
// `50 == "50"` is false in Home Assistant.
expect(evaluateBattery('50', 50)).toBe(false);
});
it('should treat a boolean attribute as equal to its integer form', () => {
// Python's `bool` is a subtype of `int`, so `true == 1`.
expect(evaluateBattery(1, true)).toBe(true);
expect(evaluateBattery(true, 1)).toBe(true);
expect(evaluateBattery(0, true)).toBe(false);
});
it('should match a falsy attribute value of zero', () => {
// `0` is a real attribute value, not the "no value" sentinel.
expect(evaluateBattery(0, 0)).toBe(true);
});
it('should match a list of numeric values by membership', () => {
expect(evaluateBattery([50, 80], 80)).toBe(true);
expect(evaluateBattery([50, 80], 20)).toBe(false);
});
it('should match a present null attribute against state: null', () => {
const evaluator = createConditionEvaluator(
{
condition: 'state' as const,
entity_id: 'sensor.foo',
attribute: 'bar',
state: null,
},
createEvaluatorContext(),
);
// A present `null` value is a real value and matches.
expect(
evaluator.evaluate({
hass: createHASS({
'sensor.foo': createStateEntity({ attributes: { bar: null } }),
}),
}).result,
).toBeTruthy();
// A non-null value does not.
expect(
evaluator.evaluate({
hass: createHASS({
'sensor.foo': createStateEntity({ attributes: { bar: 'x' } }),
}),
}).result,
).toBeFalsy();
});
it('should not match a missing attribute key even against state: null', () => {
// HA distinguishes an absent key (no match) from a present `null` value.
const evaluator = createConditionEvaluator(
{
condition: 'state' as const,
entity_id: 'sensor.foo',
attribute: 'bar',
state: null,
},
createEvaluatorContext(),
);
expect(
evaluator.evaluate({
hass: createHASS({ 'sensor.foo': createStateEntity({ attributes: {} }) }),
}).result,
).toBeFalsy();
});
it('should not treat an inherited property name as an attribute', () => {
// `toString` etc. exist on the prototype but are not real attribute keys.
const evaluator = createConditionEvaluator(
{
condition: 'state' as const,
entity_id: 'sensor.foo',
attribute: 'toString',
state_not: 'x',
},
createEvaluatorContext(),
);
// The attribute is absent, so the condition cannot match (not "not x").
expect(
evaluator.evaluate({
hass: createHASS({ 'sensor.foo': createStateEntity({ attributes: {} }) }),
}).result,
).toBeFalsy();
});
it('should resolve an input helper expected value on the attribute path', () => {
// HA resolves an `input_*` helper name to its state on the attribute path
// too, and compares against the resolved state (not the literal name).
const evaluator = createConditionEvaluator(
{
condition: 'state' as const,
entity_id: 'sensor.foo',
attribute: 'linked',
state: 'input_text.expected',
},
createEvaluatorContext(),
);
// The attribute value matches the helper's resolved state.
expect(
evaluator.evaluate({
hass: createHASS({
'sensor.foo': createStateEntity({ attributes: { linked: 'bar' } }),
'input_text.expected': createStateEntity({ state: 'bar' }),
}),
}).result,
).toBeTruthy();
// The literal helper name is not matched (only the resolved state is).
expect(
evaluator.evaluate({
hass: createHASS({
'sensor.foo': createStateEntity({
attributes: { linked: 'input_text.expected' },
}),
'input_text.expected': createStateEntity({ state: 'bar' }),
}),
}).result,
).toBeFalsy();
});
it('should not resolve a non-input entity name on the attribute path', () => {
// Only `input_*` helpers are resolved; other entity names compare literally.
const evaluator = createConditionEvaluator(
{
condition: 'state' as const,
entity_id: 'sensor.foo',
attribute: 'linked',
state: 'sensor.other',
},
createEvaluatorContext(),
);
expect(
evaluator.evaluate({
hass: createHASS({
'sensor.foo': createStateEntity({ attributes: { linked: 'bar' } }),
'sensor.other': createStateEntity({ state: 'bar' }),
}),
}).result,
).toBeFalsy();
});
it('should not match when a named input helper is missing', () => {
// HA errors when the referenced helper is unavailable; the card treats it
// as no value, so it matches nothing.
const evaluator = createConditionEvaluator(
{
condition: 'state' as const,
entity_id: 'sensor.foo',
attribute: 'linked',
state: 'input_text.expected',
},
createEvaluatorContext(),
);
expect(
evaluator.evaluate({
hass: createHASS({
'sensor.foo': createStateEntity({ attributes: { linked: 'bar' } }),
}),
}).result,
).toBeFalsy();
});
it('should match any raw change of the attribute when neither state nor state_not is set', () => {
const evaluator = createConditionEvaluator(
{
condition: 'state' as const,
entity_id: 'sensor.battery',
attribute: 'battery_level',
},
createEvaluatorContext(),
);
const evaluateChange = (to: unknown, from: unknown): boolean =>
!!evaluator.evaluate(
{
hass: createHASS({
'sensor.battery': createStateEntity({ attributes: { battery_level: to } }),
}),
},
{
hass: createHASS({
'sensor.battery': createStateEntity({
attributes: { battery_level: from },
}),
}),
},
).result;
// A real numeric change matches; a raw-equal value does not.
expect(evaluateChange(60, 50)).toBe(true);
expect(evaluateChange(50, 50)).toBe(false);
});
it('should still match non-attribute state as strings', () => {
// Regression guard: without `attribute`, matching stays string-vs-string.
const evaluator = createConditionEvaluator(
{ condition: 'state' as const, entity_id: 'sensor.battery', state: '50' },
createEvaluatorContext(),
);
expect(
evaluator.evaluate({
hass: createHASS({ 'sensor.battery': createStateEntity({ state: '50' }) }),
}).result,
).toBeTruthy();
});
});
describe('for', () => { describe('for', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -327,7 +566,7 @@ describe('state condition', () => {
}).result, }).result,
).toBeFalsy(); ).toBeFalsy();
// Held 8s (>= 5s) -> match. // Held 8s (> 5s) -> match.
expect( expect(
evaluator.evaluate({ evaluator.evaluate({
hass: createHASS({ hass: createHASS({
@@ -340,6 +579,31 @@ describe('state condition', () => {
).toBeTruthy(); ).toBeTruthy();
}); });
it('should not match when held for exactly the duration', () => {
// HA compares strictly (`>`): held == for is not yet a match.
const evaluator = createConditionEvaluator(
{
condition: 'state' as const,
entity_id: 'binary_sensor.foo',
state: 'on',
for: '00:00:05',
},
createEvaluatorContext(),
);
// Held exactly 5s (now 22:56:56, last_changed 22:56:51).
expect(
evaluator.evaluate({
hass: createHASS({
'binary_sensor.foo': createStateEntity({
state: 'on',
last_changed: '2026-06-05T22:56:51Z',
}),
}),
}).result,
).toBeFalsy();
});
it('should render a templated "for" before comparing', async () => { it('should render a templated "for" before comparing', async () => {
// This case renders a real templated `for`, so load the lazily-imported // This case renders a real templated `for`, so load the lazily-imported
// engine for the synchronous renderer. // engine for the synchronous renderer.
@@ -448,6 +712,98 @@ describe('state condition', () => {
).toBeFalsy(); ).toBeFalsy();
}); });
it('should compare an input helper by its state, not its literal name', () => {
// HA replaces the helper name with its state, so the literal name never
// matches (resolved-value-only).
const evaluator = createConditionEvaluator(
{
condition: 'state' as const,
entity_id: 'binary_sensor.foo',
state: 'input_text.expected',
},
createEvaluatorContext(),
);
expect(
evaluator.evaluate({
hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'input_text.expected' }),
'input_text.expected': createStateEntity({ state: 'armed' }),
}),
}).result,
).toBeFalsy();
});
it('should not resolve a non-input entity name', () => {
// Only `input_*` helpers are resolved; other entity names compare literally.
const evaluator = createConditionEvaluator(
{
condition: 'state' as const,
entity_id: 'binary_sensor.foo',
state: 'sensor.other',
},
createEvaluatorContext(),
);
// Not resolved: the watched state does not match the helper's state.
expect(
evaluator.evaluate({
hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'armed' }),
'sensor.other': createStateEntity({ state: 'armed' }),
}),
}).result,
).toBeFalsy();
// The literal name is compared as-is.
expect(
evaluator.evaluate({
hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'sensor.other' }),
'sensor.other': createStateEntity({ state: 'armed' }),
}),
}).result,
).toBeTruthy();
});
it('should fail when an unavailable input helper is reached before a match', () => {
// HA scans in order and raises at the unavailable `input_*` helper before it
// would reach (and match) the later `bar`.
const evaluator = createConditionEvaluator(
{
condition: 'state' as const,
entity_id: 'binary_sensor.foo',
state: ['input_text.missing', 'bar'],
},
createEvaluatorContext(),
);
expect(
evaluator.evaluate({
hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'bar' }) }),
}).result,
).toBeFalsy();
});
it('should match a value listed before an unavailable input helper', () => {
// HA breaks on the first match, so an unavailable helper listed *after* the
// matching value is never reached.
const evaluator = createConditionEvaluator(
{
condition: 'state' as const,
entity_id: 'binary_sensor.foo',
state: ['bar', 'input_text.missing'],
},
createEvaluatorContext(),
);
expect(
evaluator.evaluate({
hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'bar' }) }),
}).result,
).toBeTruthy();
});
it('should match a state_not against an empty-string entity state', () => { it('should match a state_not against an empty-string entity state', () => {
const evaluator = createConditionEvaluator( const evaluator = createConditionEvaluator(
{ condition: 'state' as const, entity_id: 'sensor.foo', state_not: 'on' }, { condition: 'state' as const, entity_id: 'sensor.foo', state_not: 'on' },
@@ -143,6 +143,21 @@ describe('StateTrigger', () => {
expect(callback).toHaveBeenCalledTimes(2); expect(callback).toHaveBeenCalledTimes(2);
}); });
it('should match a transition to an empty-string state', () => {
// HA compares `state == ''`; the empty string is a real value to match.
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
to: '',
});
trigger.subscribe(callback);
setHass(stateManager, { [ENTITY]: { state: 'on' } });
expect(callback).not.toHaveBeenCalled();
setHass(stateManager, { [ENTITY]: { state: '' } });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should fan out independently over a list of entities', () => { it('should fan out independently over a list of entities', () => {
const { trigger, stateManager, callback } = create({ const { trigger, stateManager, callback } = create({
trigger: 'state', trigger: 'state',
@@ -329,6 +344,140 @@ describe('StateTrigger', () => {
expect(callback).not.toHaveBeenCalled(); expect(callback).not.toHaveBeenCalled();
}); });
describe('attribute matching by type', () => {
it('should match a numeric attribute against an unquoted number', () => {
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'battery_level',
to: 50,
});
trigger.subscribe(callback);
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 20 } } });
expect(callback).not.toHaveBeenCalled();
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 50 } } });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should not match a numeric attribute against a stringified number', () => {
// `50 == "50"` is false in Home Assistant.
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'battery_level',
to: '50',
});
trigger.subscribe(callback);
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 20 } } });
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 50 } } });
expect(callback).not.toHaveBeenCalled();
});
it('should treat a boolean attribute as equal to its integer form', () => {
// Python's `bool` is a subtype of `int`, so `true == 1`.
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'charging',
to: 1,
});
trigger.subscribe(callback);
setHass(stateManager, { [ENTITY]: { attributes: { charging: false } } });
expect(callback).not.toHaveBeenCalled();
setHass(stateManager, { [ENTITY]: { attributes: { charging: true } } });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should match a list of numeric values by membership', () => {
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'battery_level',
to: [50, 80],
});
trigger.subscribe(callback);
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 20 } } });
expect(callback).not.toHaveBeenCalled();
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 80 } } });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should match a falsy attribute value of zero', () => {
// `0` is a real attribute value, not the "no value" sentinel, and must
// not be dropped (e.g. by `arrayify`).
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'battery_level',
to: 0,
});
trigger.subscribe(callback);
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 5 } } });
expect(callback).not.toHaveBeenCalled();
setHass(stateManager, { [ENTITY]: { attributes: { battery_level: 0 } } });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should not trigger when the raw attribute value is unchanged', () => {
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'battery_level',
to: 50,
});
trigger.subscribe(callback);
setHass(stateManager, {
[ENTITY]: { state: 'on', attributes: { battery_level: 50 } },
});
expect(callback).toHaveBeenCalledTimes(1);
// The state changes but the watched numeric attribute does not -> ignored.
setHass(stateManager, {
[ENTITY]: { state: 'off', attributes: { battery_level: 50 } },
});
expect(callback).toHaveBeenCalledTimes(1);
});
it('should match a null attribute value via to: [null]', () => {
// HA collapses a missing/None attribute to `None`; `to: [null]` matches it.
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'bar',
to: [null],
});
trigger.subscribe(callback);
setHass(stateManager, { [ENTITY]: { attributes: { bar: 'x' } } });
expect(callback).not.toHaveBeenCalled();
setHass(stateManager, { [ENTITY]: { attributes: { bar: null } } });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should exclude a null attribute value via not_to: [null]', () => {
const { trigger, stateManager, callback } = create({
trigger: 'state',
entity_id: ENTITY,
attribute: 'bar',
not_to: [null],
});
trigger.subscribe(callback);
// To a real value -> fires.
setHass(stateManager, { [ENTITY]: { attributes: { bar: 'x' } } });
expect(callback).toHaveBeenCalledTimes(1);
// To null -> excluded.
setHass(stateManager, { [ENTITY]: { attributes: { bar: null } } });
expect(callback).toHaveBeenCalledTimes(1);
});
});
it('should trigger without a to_state when the entity is removed', () => { it('should trigger without a to_state when the entity is removed', () => {
const { trigger, stateManager, callback } = create({ const { trigger, stateManager, callback } = create({
trigger: 'state', trigger: 'state',
@@ -31,4 +31,30 @@ describe('stateConditionSchema', () => {
}), }),
).toEqual({ condition: 'state', entity_id: 'binary_sensor.door', state_not: 'on' }); ).toEqual({ condition: 'state', entity_id: 'binary_sensor.door', state_not: 'on' });
}); });
it('should accept a non-string state value when matching an attribute', () => {
expect(
stateConditionSchema.parse({
condition: 'state',
entity_id: 'sensor.battery',
attribute: 'battery_level',
state: 50,
}),
).toEqual({
condition: 'state',
entity_id: 'sensor.battery',
attribute: 'battery_level',
state: 50,
});
});
it('should reject a non-string state value without an attribute', () => {
expect(() =>
stateConditionSchema.parse({
condition: 'state',
entity_id: 'sensor.battery',
state: 50,
}),
).toThrow();
});
}); });
@@ -26,4 +26,25 @@ describe('stateTriggerSchema', () => {
}), }),
).toThrow(); ).toThrow();
}); });
it('should accept a non-string to value when watching an attribute', () => {
expect(
stateTriggerSchema.parse({
trigger: 'state',
entity_id: 'sensor.battery',
attribute: 'battery_level',
to: 50,
}),
).toMatchObject({ attribute: 'battery_level', to: 50 });
});
it('should reject a non-string to value without an attribute', () => {
expect(() =>
stateTriggerSchema.parse({
trigger: 'state',
entity_id: 'sensor.battery',
to: 50,
}),
).toThrow();
});
}); });
+17
View File
@@ -5,6 +5,7 @@ import {
allPromises, allPromises,
arefloatsApproximatelyEqual, arefloatsApproximatelyEqual,
arrayify, arrayify,
arrayifyWithFalsy,
arrayMove, arrayMove,
aspectRatioToStyle, aspectRatioToStyle,
contentsChanged, contentsChanged,
@@ -69,6 +70,22 @@ describe('arrayify', () => {
}); });
}); });
describe('arrayifyWithFalsy', () => {
it('should wrap a non-array in an array', () => {
expect(arrayifyWithFalsy(1)).toEqual([1]);
});
it('should return an existing array unchanged', () => {
const data = [1, 2, 3];
expect(arrayifyWithFalsy(data)).toBe(data);
});
it('should preserve falsy values instead of dropping them', () => {
expect(arrayifyWithFalsy(0)).toEqual([0]);
expect(arrayifyWithFalsy(false)).toEqual([false]);
expect(arrayifyWithFalsy('')).toEqual(['']);
expect(arrayifyWithFalsy(null)).toEqual([null]);
});
});
describe('setify', () => { describe('setify', () => {
it('should convert non set to set', () => { it('should convert non set to set', () => {
expect(setify(1)).toEqual(new Set([1])); expect(setify(1)).toEqual(new Set([1]));
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { forwardIssues } from '../../../src/utils/zod/forward-issues';
// Parse `value` through a schema whose `superRefine` delegates to
// `forwardIssues`, returning the resulting error (or null on success).
const runThrough = (
value: unknown,
target: z.ZodType,
path?: PropertyKey[],
): z.ZodError | null => {
const result = z
.unknown()
.superRefine((v, ctx) => forwardIssues(ctx, v, target, path))
.safeParse(value);
return result.success ? null : result.error;
};
describe('forwardIssues', () => {
it('should add no issues when the value satisfies the schema', () => {
expect(runThrough('on', z.string())).toBeNull();
});
it('should forward issues with the given path prefix', () => {
const error = runThrough(5, z.string(), ['field']);
expect(error?.issues).toHaveLength(1);
expect(error?.issues[0].path).toEqual(['field']);
});
it('should forward at the issue path when no prefix is given', () => {
const error = runThrough(5, z.string());
expect(error?.issues[0].path).toEqual([]);
});
it('should prepend the prefix to nested issue paths', () => {
const error = runThrough({ inner: 5 }, z.object({ inner: z.string() }), ['outer']);
expect(error?.issues[0].path).toEqual(['outer', 'inner']);
});
});