diff --git a/docs/configuration/conditions-triggers.md b/docs/configuration/conditions-triggers.md index 3c64c066..daf26ed3 100644 --- a/docs/configuration/conditions-triggers.md +++ b/docs/configuration/conditions-triggers.md @@ -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 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 ```yaml diff --git a/src/condition-trigger/conditions/conditions/state.ts b/src/condition-trigger/conditions/conditions/state.ts index 7a336a1c..23ee812c 100644 --- a/src/condition-trigger/conditions/conditions/state.ts +++ b/src/condition-trigger/conditions/conditions/state.ts @@ -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_]+(? + 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; diff --git a/src/condition-trigger/triggers/triggers/state.ts b/src/condition-trigger/triggers/triggers/state.ts index 96562581..8e1423aa 100644 --- a/src/condition-trigger/triggers/triggers/state.ts +++ b/src/condition-trigger/triggers/triggers/state.ts @@ -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> ); } - 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> 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> // 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; diff --git a/src/config/schema/condition-trigger/common/state.ts b/src/config/schema/condition-trigger/common/state.ts index 3766338d..dbc2e7ec 100644 --- a/src/config/schema/condition-trigger/common/state.ts +++ b/src/config/schema/condition-trigger/common/state.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { forwardIssues } from '../../../../utils/zod/forward-issues'; +import { stringOrArray } from '../../common/string-or-array'; import { timePeriodSchema } from '../../common/time-period'; // 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. 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, + ]); +}; diff --git a/src/config/schema/condition-trigger/conditions/stock/state.ts b/src/config/schema/condition-trigger/conditions/stock/state.ts index ed47f47e..e9114889 100644 --- a/src/config/schema/condition-trigger/conditions/stock/state.ts +++ b/src/config/schema/condition-trigger/conditions/stock/state.ts @@ -1,7 +1,10 @@ import { z } from 'zod'; -import { stringOrArray } from '../../../common/string-or-array'; -import { stateBaseSchema } from '../../common/state'; +import { + checkStateMatchField, + stateBaseSchema, + stateMatchValueSchema, +} from '../../common/state'; import { conditionBaseSchema } from '../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). condition: z.literal('state').optional(), - // Common to both of Home Assistant's condition dialects: - state: stringOrArray.optional(), - - // Only present in HA picture elements dialect (not automation dialect), but - // respected in both usecases in this card. + // Without `attribute` these are string/list state matchers (enforced by the + // `superRefine` below); with `attribute` they compare raw against the + // attribute value, so any type is accepted. + // + // `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 - state_not: stringOrArray.optional(), + state: stateMatchValueSchema, + state_not: stateMatchValueSchema, // How a list of entities is combined: `all` (the default) requires every // entity to match, `any` requires at least one. @@ -31,4 +37,12 @@ export const stateConditionSchema = entityConditionBaseSchema .refine( (data) => data.state !== undefined || data.state_not !== undefined, '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 }); + }); diff --git a/src/config/schema/condition-trigger/triggers/stock/state.ts b/src/config/schema/condition-trigger/triggers/stock/state.ts index ad624cb8..e94f8dc2 100644 --- a/src/config/schema/condition-trigger/triggers/stock/state.ts +++ b/src/config/schema/condition-trigger/triggers/stock/state.ts @@ -1,7 +1,10 @@ import { z } from 'zod'; -import { stringOrArray } from '../../../common/string-or-array'; -import { stateBaseSchema } from '../../common/state'; +import { + checkStateMatchField, + stateBaseSchema, + stateMatchValueSchema, +} from '../../common/state'; import { triggerBaseSchema } from '../base'; import { entityTriggerBaseSchema } from './entity-base'; @@ -12,14 +15,16 @@ export const stateTriggerSchema = entityTriggerBaseSchema .extend({ trigger: z.literal('state'), - // HA accepts `null` here, distinct from omitting the key: `null` matches - // any state value, but specifying it (vs. omitting all of from/to/not_*) - // restricts firing to real state changes rather than potentially - // attribute-only changes. - from: stringOrArray.nullable().optional(), - to: stringOrArray.nullable().optional(), - not_from: stringOrArray.nullable().optional(), - not_to: stringOrArray.nullable().optional(), + // Without `attribute` these are string/list state matchers (enforced by the + // `superRefine` below); with `attribute` they compare raw against the + // attribute value, so any type is accepted. HA also accepts `null` here, + // distinct from omitting the key: `null` matches any state value, but + // specifying it (vs. omitting all of from/to/not_*) restricts firing to real + // state changes rather than potentially attribute-only changes. + from: stateMatchValueSchema, + to: stateMatchValueSchema, + not_from: stateMatchValueSchema, + not_to: stateMatchValueSchema, }) // HA makes `from`/`not_from` and `to`/`not_to` mutually exclusive (vol.Exclusive). .refine( @@ -29,4 +34,14 @@ export const stateTriggerSchema = entityTriggerBaseSchema .refine( (data) => !(data.to !== undefined && data.not_to !== undefined), '`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 }); + }); diff --git a/src/ha/event-match.ts b/src/ha/event-match.ts index 17c8004a..77e3bed9 100644 --- a/src/ha/event-match.ts +++ b/src/ha/event-match.ts @@ -13,7 +13,7 @@ const isDict = (value: unknown): value is Record => // 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 // 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) => { if (typeof x === 'boolean' && typeof y === 'number') { return Number(x) === y; diff --git a/src/utils/basic.ts b/src/utils/basic.ts index fa8a0b6e..ccddc189 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -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). * @returns An array. */ @@ -50,6 +53,16 @@ export const arrayify = (value?: T | T[]): T[] => { 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 = (value: T | T[]): T[] => + Array.isArray(value) ? value : [value]; + /** * 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) diff --git a/src/utils/zod/forward-issues.ts b/src/utils/zod/forward-issues.ts new file mode 100644 index 00000000..dbb64e9b --- /dev/null +++ b/src/utils/zod/forward-issues.ts @@ -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] }); + } + } +}; diff --git a/tests/condition-trigger/conditions/conditions/state.test.ts b/tests/condition-trigger/conditions/conditions/state.test.ts index 6c7dce22..e3217120 100644 --- a/tests/condition-trigger/conditions/conditions/state.test.ts +++ b/tests/condition-trigger/conditions/conditions/state.test.ts @@ -295,6 +295,245 @@ describe('state condition', () => { ).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', () => { beforeEach(() => { vi.useFakeTimers(); @@ -327,7 +566,7 @@ describe('state condition', () => { }).result, ).toBeFalsy(); - // Held 8s (>= 5s) -> match. + // Held 8s (> 5s) -> match. expect( evaluator.evaluate({ hass: createHASS({ @@ -340,6 +579,31 @@ describe('state condition', () => { ).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 () => { // This case renders a real templated `for`, so load the lazily-imported // engine for the synchronous renderer. @@ -448,6 +712,98 @@ describe('state condition', () => { ).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', () => { const evaluator = createConditionEvaluator( { condition: 'state' as const, entity_id: 'sensor.foo', state_not: 'on' }, diff --git a/tests/condition-trigger/triggers/triggers/state.test.ts b/tests/condition-trigger/triggers/triggers/state.test.ts index 8659af69..7a8d522b 100644 --- a/tests/condition-trigger/triggers/triggers/state.test.ts +++ b/tests/condition-trigger/triggers/triggers/state.test.ts @@ -143,6 +143,21 @@ describe('StateTrigger', () => { 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', () => { const { trigger, stateManager, callback } = create({ trigger: 'state', @@ -329,6 +344,140 @@ describe('StateTrigger', () => { 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', () => { const { trigger, stateManager, callback } = create({ trigger: 'state', diff --git a/tests/config/schema/condition-trigger/conditions/stock/state.test.ts b/tests/config/schema/condition-trigger/conditions/stock/state.test.ts index c70f9843..13e4440d 100644 --- a/tests/config/schema/condition-trigger/conditions/stock/state.test.ts +++ b/tests/config/schema/condition-trigger/conditions/stock/state.test.ts @@ -31,4 +31,30 @@ describe('stateConditionSchema', () => { }), ).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(); + }); }); diff --git a/tests/config/schema/condition-trigger/triggers/stock/state.test.ts b/tests/config/schema/condition-trigger/triggers/stock/state.test.ts index 3feca932..3a7a3d43 100644 --- a/tests/config/schema/condition-trigger/triggers/stock/state.test.ts +++ b/tests/config/schema/condition-trigger/triggers/stock/state.test.ts @@ -26,4 +26,25 @@ describe('stateTriggerSchema', () => { }), ).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(); + }); }); diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts index 8fca3668..96f134e6 100644 --- a/tests/utils/basic.test.ts +++ b/tests/utils/basic.test.ts @@ -5,6 +5,7 @@ import { allPromises, arefloatsApproximatelyEqual, arrayify, + arrayifyWithFalsy, arrayMove, aspectRatioToStyle, 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', () => { it('should convert non set to set', () => { expect(setify(1)).toEqual(new Set([1])); diff --git a/tests/utils/zod/forward-issues.test.ts b/tests/utils/zod/forward-issues.test.ts new file mode 100644 index 00000000..bc729915 --- /dev/null +++ b/tests/utils/zod/forward-issues.test.ts @@ -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']); + }); +});