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