fix: Match state trigger/condition semantics to HA (#2565)
* Closes #2530
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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`
|
||||
// (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;
|
||||
|
||||
+14
-1
@@ -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 = <T>(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 = <T>(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)
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user