feat: Align automations with Home Assistant triggers and conditions (#2527)
Split automations into HA-style `triggers`, ongoing `conditions`, and
`actions`, with compatibility migrations for existing Advanced Camera
Card configs.
## Summary
At a glance (details below):
- **Added** `triggers:` -- a required, HA-shaped block: stock `state` /
`numeric_state` / `template` plus card-specific triggers (`camera`,
`view`, `fullscreen`, ...).
- **Added** the HA-native `if` / `then` / `else` action.
- **Removed** `actions_not` (replaced by `if` / `then` / `else`).
- **Removed** the ambient `advanced_camera_card` template namespace (use
`acc` instead).
- **Changed** the trigger template surface to a top-level `trigger.*`
variable (as in HA); the nested `acc.trigger.*` paths are removed.
- **Changed** `conditions:` to ongoing gates only -- they no longer wake
an automation, and change-only forms (`config`, valueless `camera` /
`view` / `state`) become triggers, not conditions.
- **Changed** action templates to render per step, so a later action
sees state an earlier one changed.
- **Compatibility:** HA-shaped YAML is accepted (singular keys,
single-or-list, `and` / `or` / `not` shorthand, `entity` / `entity_id`).
- **Migration:** existing configs upgrade automatically; anything that
cannot be converted faithfully is recorded under `__UPGRADE_FAILURE__`
for manual fixup.
## Breaking Changes
### 1. Automations now require triggers
Before this PR, `automations[].conditions` served two roles:
- They decided whether the automation should run.
- They also acted as the thing that woke the automation up.
After this PR:
- `triggers` wake the automation.
- `conditions` only gate it at the instant a trigger fires.
Most existing automations are migrated automatically from `conditions:`
to `triggers:`.
### 2. `actions_not` is retired
Legacy `actions_not` is replaced by an HA-style `if` action with `then`
/ `else`.
Faithful conversions are automatic. Cases that cannot be faithfully
converted are recorded under `__UPGRADE_FAILURE__.automations` and must
be migrated manually.
### 3. Template surface aligned with Home Assistant
Two related template changes, both auto-migrated:
- **Top-level `trigger.*`.** Automation actions now receive a top-level
`trigger` template variable, like Home Assistant. Legacy nested paths
such as `acc.trigger.state.to` and
`advanced_camera_card.trigger.camera.to` are migrated automatically when
they appear inside template strings.
- **The ambient `advanced_camera_card` template namespace is removed.**
The long-form ambient namespace (`advanced_camera_card.camera`,
`advanced_camera_card.view`, `advanced_camera_card.config`) is retired
in favour of its shorter `acc` alias -- supported since v7.1.0, and the
only spelling the new trigger surface uses. Existing templates are
migrated automatically by rewriting the `advanced_camera_card.` prefix
to `acc.`.
### 4. Trigger-only condition forms are no longer valid conditions
Some legacy "conditions" were really change detectors. These are now
triggers only:
- `condition: config`
- valueless `camera`
- valueless `view`
- valueless `state` / picture-elements state condition with neither
`state` nor `state_not`
These are automatically promoted in automations and stripped from
overrides/elements where they would no longer be meaningful as ongoing
conditions.
### 5. Template truthiness now follows Home Assistant behavior
Template conditions and template triggers intentionally use different
truthiness rules, matching HA:
- A template condition passes only when the rendered value is `true`
(case-insensitive), matching HA's `condition.py`.
- A template trigger uses HA's broader `result_as_boolean` coercion: a
non-zero number, or `1` / `true` / `yes` / `on` / `enable`
(case-insensitive), counts as true.
### 6. Action templates render when each action executes
Action templates are now rendered per action step, not once for the
whole sequence. This means a later action can see card-local state
changed by an earlier action in the same sequence.
The `trigger` context is fixed for the automation run. HA entity state
updates still depend on the frontend receiving updated HASS state over
the websocket.
## Automatic Migrations
### Automation `conditions:` to `triggers:`
Simple legacy automation:
```yaml
# Before
automations:
- conditions:
- condition: fullscreen
fullscreen: true
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_on
```
```yaml
# After, automatic
automations:
- triggers:
- trigger: fullscreen
fullscreen: true
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_on
```
State conditions become HA-style state triggers:
```yaml
# Before
automations:
- conditions:
- condition: state
entity_id: binary_sensor.front_door
state: 'on'
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: live
```
```yaml
# After, automatic
automations:
- triggers:
- trigger: state
entity_id: binary_sensor.front_door
to: 'on'
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: live
```
Multiple conditions become both triggers and ongoing conditions:
```yaml
# Before
automations:
- conditions:
- condition: camera
cameras: [front_door]
- condition: fullscreen
fullscreen: true
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_on
```
```yaml
# After, automatic
automations:
- triggers:
- trigger: camera
cameras: [front_door]
- trigger: fullscreen
fullscreen: true
conditions:
- condition: camera
cameras: [front_door]
- condition: fullscreen
fullscreen: true
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_on
```
The flattened trigger list is an implicit OR. The retained `conditions:`
list is an implicit AND checked when any trigger fires.
### Trigger-only legacy conditions
Legacy `config` conditions become `config` triggers:
```yaml
# Before
automations:
- conditions:
- condition: config
paths: [menu.style]
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: status_bar
```
```yaml
# After, automatic
automations:
- triggers:
- trigger: config
paths: [menu.style]
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: status_bar
```
Trigger-only leaves are removed from retained `conditions:` blocks
because they no longer describe an ongoing state.
### `actions_not` to `if` / `then` / `else`
```yaml
# Before
automations:
- conditions:
- condition: state
entity_id: input_boolean.camera_alerts
state: 'on'
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: live
actions_not:
- action: none
```
```yaml
# After, automatic
automations:
- triggers:
- trigger: state
entity_id: input_boolean.camera_alerts
actions:
- if:
- condition: state
entity_id: input_boolean.camera_alerts
state: 'on'
then:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: live
else:
- action: none
```
If the legacy automation had no conditions, or only trigger-only
conditions, `actions_not` is dropped because the old `else` branch could
not be reproduced as an ongoing predicate.
### Trigger template paths
```yaml
# Before
message: 'Door is {{ acc.trigger.state.to }} from {{ acc.trigger.state.from }}'
```
```yaml
# After, automatic
message: 'Door is {{ trigger.to_state.state }} from {{ trigger.from_state.state }}'
```
Path rewrites performed automatically:
| Old path | New path |
| -------------------------- | -------------------------- |
| `acc.trigger.state.entity` | `trigger.entity_id` |
| `acc.trigger.state.from` | `trigger.from_state.state` |
| `acc.trigger.state.to` | `trigger.to_state.state` |
| `acc.trigger.camera.from` | `trigger.from_acc.camera` |
| `acc.trigger.camera.to` | `trigger.to_acc.camera` |
| `acc.trigger.view.from` | `trigger.from_acc.view` |
| `acc.trigger.view.to` | `trigger.to_acc.view` |
| `acc.trigger.config.from` | `trigger.from_acc.config` |
| `acc.trigger.config.to` | `trigger.to_acc.config` |
The same rewrites are applied for the older
`advanced_camera_card.trigger.*` namespace.
### Ambient template namespace
Any remaining long-form ambient `advanced_camera_card.*` references
(outside the trigger surface) are rewritten to the `acc.*` alias:
```yaml
# Before
title: 'Now viewing {{ advanced_camera_card.camera }}'
```
```yaml
# After, automatic
title: 'Now viewing {{ acc.camera }}'
```
## Manual Migration Cases
### `__UPGRADE_FAILURE__.automations`
If a legacy automation cannot be converted faithfully, the original
automation is recorded under:
```yaml
__UPGRADE_FAILURE__:
automations:
- ...
```
These entries require manual migration.
The main known case is legacy `actions_not` with a condition whose
trigger can only fire on a rising edge, such as:
- `condition: template`
- `condition: screen`
- `condition: numeric_state` without an entity-backed state to watch
Those conditions can start the `then` branch, but cannot reliably start
the `else` branch when they stop matching.
### Unsupported HA conditions and triggers
This PR aligns the card with HA where supported, but it is not a full HA
automation engine.
Unsupported HA condition families include:
- `time`
- `zone`
- `sun`
- `location`
- `device`
- `condition: trigger`
Unsupported HA trigger platforms include:
- `event`
- `time`
- `time_pattern`
- `sun`
- `zone`
- `calendar`
- `webhook`
- `tag`
- `device`
- `mqtt`
The card-specific camera `triggers:` feature (which auto-selects and
wakes the card on camera events such as motion) is a separate feature
from automation `triggers:`, despite the shared word.
### Trigger IDs and variables
HA keys such as `id`, `alias`, and `variables` are accepted so pasted HA
YAML validates, but they are ignored by the card. There is no
`trigger.id` support in this PR.
## New Compatibility Features
This PR also makes card config more forgiving for HA-style YAML:
- `trigger`, `condition`, and `action` singular keys are accepted and
normalized to `triggers`, `conditions`, and `actions`.
- Single trigger, condition, and action objects are accepted where lists
are expected.
- `if`, `then`, and `else` accept a single item or a list.
- Composite condition shorthand is accepted:
- `{ and: [...] }`
- `{ or: [...] }`
- `{ not: [...] }`
- `{ condition: [...] }` as an implicit AND
- State conditions resolve expected state values that name another
entity, matching HA/Lovelace behavior.
- Both `entity` and `entity_id` are accepted on state and numeric
conditions and triggers (a superset of HA's two dialects), so there is
no forced rename.
- `state_not` remains supported as a card/Lovelace-friendly extension.
## Trigger Payloads
Automation action templates receive a top-level `trigger` object.
For stock `state` and `numeric_state` triggers:
```yaml
trigger.platform
trigger.entity_id
trigger.entity
trigger.from_state
trigger.to_state
```
For template triggers:
```yaml
trigger.platform
```
For card-specific triggers:
```yaml
trigger.platform # "acc"
trigger.type
trigger.from_acc
trigger.to_acc
```
The card does not currently expose HA's `id`, `idx`, `for`, `attribute`,
`above`, `below`, or `alias` trigger fields.
BREAKING CHANGE: Automations now follow Home Assistant's `triggers:` /
`conditions:` / `actions:` model. Automations require a `triggers:`
block and `conditions:` no longer wake an automation; `actions_not` is
removed in favour of an `if` / `then` / `else` action; the nested
`acc.trigger.*` template paths and the ambient `advanced_camera_card`
template namespace are removed (use the top-level `trigger.*` surface
and the `acc` alias); trigger-only condition forms (`config`, valueless
`camera` / `view` / `state`) are no longer valid conditions; and
template-condition vs template-trigger truthiness now follow HA.
Existing configs are upgraded automatically where a faithful conversion
exists; anything that cannot be converted is recorded under
`__UPGRADE_FAILURE__` for manual migration.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
committed by
dermotduffy
co-authored by
Claude Opus 4.8
parent
209c873c58
commit
b701366762
+589
-22
@@ -9,6 +9,7 @@ import {
|
||||
CONF_OVERRIDES,
|
||||
CONF_PROFILES,
|
||||
CONF_STATUS_BAR,
|
||||
CONF_UPGRADE_FAILURE,
|
||||
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
|
||||
CONF_VIEW_DEFAULT_RESET_ENTITIES,
|
||||
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
|
||||
@@ -21,8 +22,8 @@ import {
|
||||
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
|
||||
CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS,
|
||||
} from '../const';
|
||||
import { arrayify } from '../utils/basic';
|
||||
import { AdvancedCameraCardCondition } from './schema/conditions/types';
|
||||
import { arrayify, isRecord } from '../utils/basic';
|
||||
import { Condition } from './schema/condition-trigger/conditions/types';
|
||||
import { RawAdvancedCameraCardConfig, RawAdvancedCameraCardConfigArray } from './types';
|
||||
|
||||
// *************************************************************************
|
||||
@@ -362,7 +363,7 @@ const conditionToConditionsTransform = (data: unknown): boolean => {
|
||||
}
|
||||
|
||||
const oldConditions = data['conditions'];
|
||||
const newConditions: AdvancedCameraCardCondition[] = [];
|
||||
const newConditions: Condition[] = [];
|
||||
|
||||
if (oldConditions['view'] !== undefined) {
|
||||
newConditions.push({
|
||||
@@ -412,7 +413,7 @@ const conditionToConditionsTransform = (data: unknown): boolean => {
|
||||
state_not: stateCondition['state_not'],
|
||||
}),
|
||||
...(stateCondition['entity'] && {
|
||||
entity: stateCondition['entity'],
|
||||
entity_id: stateCondition['entity'],
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -438,10 +439,551 @@ const conditionToConditionsTransform = (data: unknown): boolean => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const isCompositeCondition = (condition: unknown): boolean => {
|
||||
if (!isRecord(condition)) {
|
||||
return false;
|
||||
}
|
||||
const kind = condition['condition'];
|
||||
return typeof kind === 'string' && ['or', 'and', 'not'].includes(kind);
|
||||
};
|
||||
|
||||
// Triggers are a flat OR list with no composites, so a composite condition is
|
||||
// reduced to its leaf conditions for the trigger list (the composite itself is
|
||||
// retained on the `conditions:` side).
|
||||
const flattenConditionLeaves = (condition: unknown): unknown[] => {
|
||||
if (!isCompositeCondition(condition) || !isRecord(condition)) {
|
||||
return [condition];
|
||||
}
|
||||
const inner = condition['conditions'];
|
||||
return Array.isArray(inner) ? inner.flatMap(flattenConditionLeaves) : [];
|
||||
};
|
||||
|
||||
// A condition that fired on a *change* rather than describing an ongoing state
|
||||
// was really a trigger (the legacy "conditions-as-triggers" model). Migration
|
||||
// promotes it to a trigger and drops it from the retained `conditions:`.
|
||||
//
|
||||
// Note: `config` is no longer a condition; and although the current schema
|
||||
// reads a valueless `camera` as "any camera selected", a *legacy* valueless
|
||||
// `camera` meant the change, so it is still trigger-only here).
|
||||
const isTriggerOnlyCondition = (condition: unknown): boolean => {
|
||||
if (!isRecord(condition)) {
|
||||
return false;
|
||||
}
|
||||
const kind = condition['condition'];
|
||||
if (kind === 'config') {
|
||||
// `config` is no longer a condition at all.
|
||||
return true;
|
||||
}
|
||||
if (kind === 'camera') {
|
||||
return !(Array.isArray(condition['cameras']) && condition['cameras'].length);
|
||||
}
|
||||
if (kind === 'view') {
|
||||
return !(Array.isArray(condition['views']) && condition['views'].length);
|
||||
}
|
||||
if (kind === 'state' || kind === undefined) {
|
||||
return condition['state'] === undefined && condition['state_not'] === undefined;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Drop trigger-only conditions from a retained `conditions:` list, recursing
|
||||
// into composites and discarding any that become empty.
|
||||
const dropTriggerOnlyConditions = (conditions: unknown[]): unknown[] => {
|
||||
const kept: unknown[] = [];
|
||||
for (const condition of conditions) {
|
||||
if (
|
||||
isCompositeCondition(condition) &&
|
||||
typeof condition === 'object' &&
|
||||
condition &&
|
||||
Array.isArray(condition['conditions'])
|
||||
) {
|
||||
const inner = dropTriggerOnlyConditions(condition['conditions']);
|
||||
if (inner.length) {
|
||||
kept.push({ ...condition, conditions: inner });
|
||||
}
|
||||
} else if (!isTriggerOnlyCondition(condition)) {
|
||||
kept.push(condition);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
};
|
||||
|
||||
const rewriteConditionAsTrigger = (condition: unknown): unknown => {
|
||||
if (!isRecord(condition)) {
|
||||
return condition;
|
||||
}
|
||||
const kind = condition['condition'];
|
||||
|
||||
// A `state` condition maps onto the HA state trigger (`state` -> `to`,
|
||||
// `state_not` -> `not_to`). A discriminator-less condition is the bare
|
||||
// picture-element state form -- the only condition that may omit `condition`.
|
||||
if (kind === 'state' || kind === undefined) {
|
||||
const entityId = condition['entity_id'] ?? condition['entity'];
|
||||
return {
|
||||
trigger: 'state',
|
||||
...(entityId !== undefined && { entity_id: entityId }),
|
||||
...(condition['state'] !== undefined && { to: condition['state'] }),
|
||||
...(condition['state_not'] !== undefined && { not_to: condition['state_not'] }),
|
||||
};
|
||||
}
|
||||
|
||||
// Every other condition -- the stock `numeric_state`/`template` and all the
|
||||
// card-specific kinds -- shares its field names with the matching trigger
|
||||
// (only `state` involves internal field renames), so promoting is just a
|
||||
// discriminator swap.
|
||||
const rest = { ...condition };
|
||||
delete rest['condition'];
|
||||
return { trigger: kind, ...rest };
|
||||
};
|
||||
|
||||
/**
|
||||
* Promote an automation's `conditions:` into HA-native `triggers:`.
|
||||
*
|
||||
* A single simple condition becomes one trigger and the `conditions:` block is
|
||||
* dropped. Multiple conditions (or a composite) become one trigger per leaf,
|
||||
* while the original `conditions:` are retained as an ongoing predicate
|
||||
* (dual-list) -- minus any trigger-only forms, which would no longer be valid
|
||||
* conditions. Idempotent: an automation that already has `triggers:` is left
|
||||
* untouched.
|
||||
*/
|
||||
const promoteConditionsToTriggersTransform = (data: unknown): boolean => {
|
||||
if (!isRecord(data) || 'triggers' in data) {
|
||||
return false;
|
||||
}
|
||||
const conditions = data['conditions'];
|
||||
if (!Array.isArray(conditions) || !conditions.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (conditions.length === 1 && !isCompositeCondition(conditions[0])) {
|
||||
data['triggers'] = [rewriteConditionAsTrigger(conditions[0])];
|
||||
delete data['conditions'];
|
||||
} else {
|
||||
data['triggers'] = conditions
|
||||
.flatMap(flattenConditionLeaves)
|
||||
.map(rewriteConditionAsTrigger);
|
||||
const ongoing = dropTriggerOnlyConditions(conditions);
|
||||
if (ongoing.length) {
|
||||
data['conditions'] = ongoing;
|
||||
} else {
|
||||
delete data['conditions'];
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the upgrade recorded any config it could not faithfully convert,
|
||||
* under {@link CONF_UPGRADE_FAILURE} (only ever written non-empty). The config
|
||||
* is not modified.
|
||||
* @param obj The configuration.
|
||||
* @returns `true` if any failures remain.
|
||||
*/
|
||||
export const hasConfigUpgradeFailures = (
|
||||
obj: RawAdvancedCameraCardConfig | null,
|
||||
): boolean => {
|
||||
const failures = obj?.[CONF_UPGRADE_FAILURE];
|
||||
return isRecord(failures) && Object.keys(failures).length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Record entries the upgrade could not faithfully convert under
|
||||
* `__UPGRADE_FAILURE__.<path>` (the namespace shadows the main config -- see
|
||||
* {@link CONF_UPGRADE_FAILURE}), appending to any already recorded there.
|
||||
* @param data The configuration, modified in place.
|
||||
* @param path The config path the entries came from (e.g. `automations`).
|
||||
* @param failures The original entries, recorded untouched.
|
||||
*/
|
||||
const addUpgradeFailures = (
|
||||
data: RawAdvancedCameraCardConfig,
|
||||
path: string,
|
||||
failures: unknown[],
|
||||
): void => {
|
||||
const upgradeFailures = isRecord(data[CONF_UPGRADE_FAILURE])
|
||||
? data[CONF_UPGRADE_FAILURE]
|
||||
: {};
|
||||
const existing = upgradeFailures[path];
|
||||
upgradeFailures[path] = [...(Array.isArray(existing) ? existing : []), ...failures];
|
||||
data[CONF_UPGRADE_FAILURE] = upgradeFailures;
|
||||
};
|
||||
|
||||
// `template`/`screen` conditions have no "any change" trigger -- their only
|
||||
// trigger fires on the rising edge alone (HA's own template/numeric_state
|
||||
// triggers behave identically, and HA has no `screen` trigger at all). An
|
||||
// automation resting on one cannot re-fire when it stops matching, so its
|
||||
// migrated `else` will not run on that falling edge.
|
||||
const RISING_EDGE_ONLY_CONDITIONS = ['template', 'screen'];
|
||||
|
||||
// Build the "fire on any change" trigger that drives a migrated `if`/`then`/
|
||||
// `else` for a single condition leaf, plus whether that trigger only sees the
|
||||
// rising edge. Returns a null trigger for conditions that cannot change at
|
||||
// runtime (`user`/`user_agent`), which therefore contribute none.
|
||||
const synthesizeAnyChangeTrigger = (
|
||||
leaf: unknown,
|
||||
): { trigger: RawAdvancedCameraCardConfig | null; risingEdgeOnly: boolean } => {
|
||||
if (!isRecord(leaf)) {
|
||||
return { trigger: null, risingEdgeOnly: false };
|
||||
}
|
||||
const kind = leaf['condition'] ?? 'state';
|
||||
|
||||
// Static within a session: no runtime change, so no trigger.
|
||||
if (kind === 'user' || kind === 'user_agent') {
|
||||
return { trigger: null, risingEdgeOnly: false };
|
||||
}
|
||||
|
||||
// Entity-backed: a plain `state` watch (no `to`) fires on every change of the
|
||||
// entity, so the wrapped `if(state)`/`if(numeric_state)` re-evaluates on both
|
||||
// edges -- the same trigger a user would hand-write in Home Assistant.
|
||||
if (kind === 'state' || kind === 'numeric_state') {
|
||||
const entityId = leaf['entity_id'] ?? leaf['entity'];
|
||||
if (entityId !== undefined) {
|
||||
return {
|
||||
trigger: { trigger: 'state', entity_id: entityId },
|
||||
risingEdgeOnly: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// `config` is trigger-only; its `paths` scope a config-change watch (still any
|
||||
// change), so they are preserved rather than dropped like a match value.
|
||||
if (kind === 'config') {
|
||||
const paths = leaf['paths'];
|
||||
return {
|
||||
trigger: { trigger: 'config', ...(paths !== undefined && { paths }) },
|
||||
risingEdgeOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Rising-edge-only kinds (and a `numeric_state` with only a `value_template`,
|
||||
// which has no entity to watch): best-effort reuse of their own trigger.
|
||||
if (
|
||||
(typeof kind === 'string' && RISING_EDGE_ONLY_CONDITIONS.includes(kind)) ||
|
||||
kind === 'numeric_state'
|
||||
) {
|
||||
const rest = { ...leaf };
|
||||
delete rest['condition'];
|
||||
return { trigger: { trigger: kind, ...rest }, risingEdgeOnly: true };
|
||||
}
|
||||
|
||||
// Card-state kinds: the valueless trigger fires on any change.
|
||||
return { trigger: { trigger: kind }, risingEdgeOnly: false };
|
||||
};
|
||||
|
||||
// Synthesize the deduplicated set of "any change" triggers for the condition
|
||||
// leaves. Conditions that are all static never change after startup, so a
|
||||
// single `initialized` evaluation is faithful.
|
||||
const synthesizeAnyChangeTriggers = (
|
||||
conditions: unknown[],
|
||||
): RawAdvancedCameraCardConfig[] => {
|
||||
const triggers: RawAdvancedCameraCardConfig[] = [];
|
||||
for (const leaf of conditions.flatMap(flattenConditionLeaves)) {
|
||||
const { trigger } = synthesizeAnyChangeTrigger(leaf);
|
||||
if (trigger && !triggers.some((existing) => isEqual(existing, trigger))) {
|
||||
triggers.push(trigger);
|
||||
}
|
||||
}
|
||||
if (!triggers.length) {
|
||||
triggers.push({ trigger: 'initialized' });
|
||||
}
|
||||
return triggers;
|
||||
};
|
||||
|
||||
// A condition leaf whose only trigger fires on the rising edge (`template`/
|
||||
// `screen`, or a `numeric_state` with no entity to watch) cannot drive the
|
||||
// `else` branch when it stops matching, so such an automation cannot be
|
||||
// faithfully converted.
|
||||
const hasRisingEdgeOnlyCondition = (conditions: unknown[]): boolean =>
|
||||
conditions
|
||||
.flatMap(flattenConditionLeaves)
|
||||
.some((leaf) => synthesizeAnyChangeTrigger(leaf).risingEdgeOnly);
|
||||
|
||||
/**
|
||||
* Convert one legacy `actions_not` automation in place to an HA-native
|
||||
* `if`/`then`/`else` action, or report that it failed to convert.
|
||||
*
|
||||
* `{ conditions: C, actions: A, actions_not: B }` becomes `{ triggers:
|
||||
* <any-change for each leaf of C>, actions: [{ if: C, then: A, else: B }] }`:
|
||||
* the `if` retains both branches and the synthesized triggers re-evaluate it on
|
||||
* every change of the conditions. When `C` has no ongoing predicate for the
|
||||
* `if` to test -- it is absent, or holds only trigger-only conditions (legacy
|
||||
* change-detectors such as a bare `camera` or a `config` condition) -- the
|
||||
* `else` branch could never run, so `actions_not` is dropped rather than
|
||||
* wrapped. Conditions with a rising-edge-only leaf are returned as `'failed'`,
|
||||
* untouched, because their `else` cannot be reproduced faithfully. Idempotent:
|
||||
* a converted automation has no `actions_not` left to reconvert.
|
||||
*/
|
||||
const convertActionsNotAutomation = (
|
||||
automation: RawAdvancedCameraCardConfig,
|
||||
): 'converted' | 'failed' => {
|
||||
const conditions = automation['conditions'];
|
||||
|
||||
if (!Array.isArray(conditions) || !conditions.length) {
|
||||
// No conditions -- `actions_not` could never have run; it is simply dropped.
|
||||
delete automation['actions_not'];
|
||||
return 'converted';
|
||||
}
|
||||
|
||||
if (hasRisingEdgeOnlyCondition(conditions)) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
const actionsNot = automation['actions_not'];
|
||||
const actions = Array.isArray(automation['actions']) ? automation['actions'] : [];
|
||||
|
||||
automation['triggers'] = synthesizeAnyChangeTriggers(conditions);
|
||||
delete automation['actions_not'];
|
||||
|
||||
// `conditions` move *into* the `if` below; they must not also remain as a
|
||||
// top-level ongoing condition, which would block the automation (and so the
|
||||
// `else` branch) whenever they fail -- exactly the case `else` exists to handle.
|
||||
delete automation['conditions'];
|
||||
|
||||
// The `if` tests only the ongoing predicates; dropping the trigger-only
|
||||
// conditions can leave nothing, in which case `else` could never run.
|
||||
const ongoing = dropTriggerOnlyConditions(conditions);
|
||||
if (!ongoing.length) {
|
||||
automation['actions'] = actions;
|
||||
return 'converted';
|
||||
}
|
||||
|
||||
automation['actions'] = [
|
||||
{
|
||||
if: ongoing,
|
||||
then: actions,
|
||||
...(Array.isArray(actionsNot) && { else: actionsNot }),
|
||||
},
|
||||
];
|
||||
return 'converted';
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate every legacy `actions_not` automation: convert the faithful ones in
|
||||
* place to `if`/`then`/`else`, and record the rest -- conditions with a
|
||||
* rising-edge-only leaf, whose `else` cannot be reproduced -- as failures,
|
||||
* untouched, under `__UPGRADE_FAILURE__.automations` for the user to migrate by
|
||||
* hand. Runs before
|
||||
* the conditions->triggers promotion, which then skips the converted ones (they
|
||||
* now have `triggers:`) and never sees the failed ones.
|
||||
*/
|
||||
const migrateActionsNotTransform = (data: unknown): boolean => {
|
||||
if (!isRecord(data) || !Array.isArray(data[CONF_AUTOMATIONS])) {
|
||||
return false;
|
||||
}
|
||||
const kept: unknown[] = [];
|
||||
const failed: unknown[] = [];
|
||||
let modified = false;
|
||||
for (const automation of data[CONF_AUTOMATIONS]) {
|
||||
if (isRecord(automation) && 'actions_not' in automation) {
|
||||
modified = true;
|
||||
if (convertActionsNotAutomation(automation) === 'failed') {
|
||||
failed.push(automation);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
kept.push(automation);
|
||||
}
|
||||
if (!modified) {
|
||||
return false;
|
||||
}
|
||||
data[CONF_AUTOMATIONS] = kept;
|
||||
if (failed.length) {
|
||||
addUpgradeFailures(data, CONF_AUTOMATIONS, failed);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Picture-element conditional wrapper types (shared with upgradePTZElementsToLive).
|
||||
const CONDITIONAL_ELEMENT_TYPES = [
|
||||
'conditional',
|
||||
'custom:advanced-camera-card-conditional',
|
||||
];
|
||||
|
||||
const isConditionalElementType = (type: unknown): boolean =>
|
||||
typeof type === 'string' && CONDITIONAL_ELEMENT_TYPES.includes(type);
|
||||
|
||||
// Strip the trigger-only conditions from a single entry's `conditions:`, in
|
||||
// place. `keep` is false when stripping emptied a non-empty `conditions:`,
|
||||
// meaning the entry has no meaningful conditions left and the caller should drop
|
||||
// it; an already-empty `conditions:` (or an entry with none) is the user's and
|
||||
// left untouched.
|
||||
const stripTriggerOnlyConditionsFromEntry = (
|
||||
entry: RawAdvancedCameraCardConfig,
|
||||
): { keep: boolean; modified: boolean } => {
|
||||
const original = entry['conditions'];
|
||||
if (!Array.isArray(original)) {
|
||||
return { keep: true, modified: false };
|
||||
}
|
||||
const stripped = dropTriggerOnlyConditions(original);
|
||||
if (original.length && !stripped.length) {
|
||||
return { keep: false, modified: true };
|
||||
}
|
||||
if (!isEqual(stripped, original)) {
|
||||
entry['conditions'] = stripped;
|
||||
return { keep: true, modified: true };
|
||||
}
|
||||
return { keep: true, modified: false };
|
||||
};
|
||||
|
||||
// Strip trigger-only conditions from the conditional elements in a picture-
|
||||
// element tree, recursing into the kept conditionals. Conditional elements
|
||||
// nest, so this is recursive; overrides are a flat list handled inline by the
|
||||
// parent transform.
|
||||
const stripTriggerOnlyConditionsFromElements = (
|
||||
elements: RawAdvancedCameraCardConfigArray,
|
||||
): { elements: RawAdvancedCameraCardConfigArray; modified: boolean } => {
|
||||
let modified = false;
|
||||
const kept: RawAdvancedCameraCardConfigArray = [];
|
||||
for (const element of elements) {
|
||||
if (
|
||||
typeof element === 'object' &&
|
||||
element &&
|
||||
isConditionalElementType(element['type'])
|
||||
) {
|
||||
const { keep, modified: entryModified } =
|
||||
stripTriggerOnlyConditionsFromEntry(element);
|
||||
modified = entryModified || modified;
|
||||
if (!keep) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(element['elements'])) {
|
||||
const inner = stripTriggerOnlyConditionsFromElements(element['elements']);
|
||||
modified = inner.modified || modified;
|
||||
element['elements'] = inner.elements;
|
||||
}
|
||||
}
|
||||
kept.push(element);
|
||||
}
|
||||
return { elements: kept, modified };
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop the now-invalid trigger-only conditions (including the removed `config`
|
||||
* condition) from the `conditions:` of overrides and conditional elements. An
|
||||
* entry whose conditions become empty has no meaningful conditions left, so it
|
||||
* is dropped entirely. Automations are handled by the promote transform.
|
||||
*/
|
||||
const stripTriggerOnlyConditionsFromOverridesElementsTransform = (
|
||||
data: unknown,
|
||||
): boolean => {
|
||||
if (!isRecord(data)) {
|
||||
return false;
|
||||
}
|
||||
let modified = false;
|
||||
|
||||
const overrides = data[CONF_OVERRIDES];
|
||||
if (Array.isArray(overrides)) {
|
||||
let overridesModified = false;
|
||||
const kept: RawAdvancedCameraCardConfigArray = [];
|
||||
for (const override of overrides) {
|
||||
if (typeof override === 'object' && override) {
|
||||
const { keep, modified: entryModified } =
|
||||
stripTriggerOnlyConditionsFromEntry(override);
|
||||
overridesModified = entryModified || overridesModified;
|
||||
if (!keep) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
kept.push(override);
|
||||
}
|
||||
if (overridesModified) {
|
||||
data[CONF_OVERRIDES] = kept;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
const elements = data[CONF_ELEMENTS];
|
||||
if (Array.isArray(elements)) {
|
||||
const result = stripTriggerOnlyConditionsFromElements(elements);
|
||||
if (result.modified) {
|
||||
modified = true;
|
||||
if (result.elements.length) {
|
||||
data[CONF_ELEMENTS] = result.elements;
|
||||
} else {
|
||||
delete data[CONF_ELEMENTS];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modified;
|
||||
};
|
||||
|
||||
// Legacy nested trigger template paths -> the HA-native top-level `trigger.*`.
|
||||
const TRIGGER_TEMPLATE_PATH_REWRITES: { suffix: string; modern: string }[] = [
|
||||
{ suffix: 'trigger.state.entity', modern: 'trigger.entity_id' },
|
||||
{ suffix: 'trigger.state.from', modern: 'trigger.from_state.state' },
|
||||
{ suffix: 'trigger.state.to', modern: 'trigger.to_state.state' },
|
||||
{ suffix: 'trigger.camera.from', modern: 'trigger.from_acc.camera' },
|
||||
{ suffix: 'trigger.camera.to', modern: 'trigger.to_acc.camera' },
|
||||
{ suffix: 'trigger.view.from', modern: 'trigger.from_acc.view' },
|
||||
{ suffix: 'trigger.view.to', modern: 'trigger.to_acc.view' },
|
||||
{ suffix: 'trigger.config.from', modern: 'trigger.from_acc.config' },
|
||||
{ suffix: 'trigger.config.to', modern: 'trigger.to_acc.config' },
|
||||
];
|
||||
|
||||
// Both the released `acc` alias and the full `advanced_camera_card` namespace are
|
||||
// migrated.
|
||||
const TRIGGER_TEMPLATE_PREFIXES = ['acc.', 'advanced_camera_card.'];
|
||||
|
||||
const rewriteTriggerTemplatePaths = (value: string): string => {
|
||||
let result = value;
|
||||
for (const prefix of TRIGGER_TEMPLATE_PREFIXES) {
|
||||
for (const { suffix, modern } of TRIGGER_TEMPLATE_PATH_REWRITES) {
|
||||
result = result.replaceAll(prefix + suffix, modern);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Apply a string rewrite to every template-string value of a single object, in
|
||||
// place. Only touches values containing a nunjucks delimiter (`{{` expression
|
||||
// or `{%` statement), i.e. template strings.
|
||||
const rewriteTemplateStrings = (
|
||||
data: RawAdvancedCameraCardConfig,
|
||||
rewrite: (value: string) => string,
|
||||
): boolean => {
|
||||
let modified = false;
|
||||
for (const key of Object.keys(data)) {
|
||||
const value = data[key];
|
||||
if (typeof value === 'string' && (value.includes('{{') || value.includes('{%'))) {
|
||||
const rewritten = rewrite(value);
|
||||
if (rewritten !== value) {
|
||||
data[key] = rewritten;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rewrite the legacy nested `acc.trigger.*` / `advanced_camera_card.trigger.*`
|
||||
* template paths to the top-level `trigger.*` surface, in place on a single
|
||||
* object's string values. Idempotent (a migrated path matches no legacy
|
||||
* pattern).
|
||||
*
|
||||
* @returns `true` if any value was rewritten.
|
||||
*/
|
||||
const migrateTriggerTemplatePathsTransform = (
|
||||
data: RawAdvancedCameraCardConfig,
|
||||
): boolean => rewriteTemplateStrings(data, rewriteTriggerTemplatePaths);
|
||||
|
||||
/**
|
||||
* Retire the ambient `advanced_camera_card.*` template namespace in favour of its
|
||||
* shorter `acc` alias (the only spelling the trigger surface uses), rewriting the
|
||||
* prefix in a single object's string values. Idempotent.
|
||||
*
|
||||
* @returns `true` if any value was rewritten.
|
||||
*/
|
||||
const migrateAmbientTemplateNamespaceTransform = (
|
||||
data: RawAdvancedCameraCardConfig,
|
||||
): boolean =>
|
||||
rewriteTemplateStrings(data, (value) =>
|
||||
value.replaceAll('advanced_camera_card.', 'acc.'),
|
||||
);
|
||||
|
||||
const callServiceToPerformActionTransform = (data: unknown): boolean => {
|
||||
if (
|
||||
typeof data !== 'object' ||
|
||||
!data ||
|
||||
!isRecord(data) ||
|
||||
data['action'] !== 'call-service' ||
|
||||
typeof data['service'] !== 'string'
|
||||
) {
|
||||
@@ -483,8 +1025,7 @@ const serviceDataToDataTransform = (data: unknown): boolean => {
|
||||
const upgradePTZElementsToLive = function (): (data: unknown) => boolean {
|
||||
return function (data: unknown): boolean {
|
||||
if (
|
||||
typeof data !== 'object' ||
|
||||
!data ||
|
||||
!isRecord(data) ||
|
||||
!(CONF_ELEMENTS in data) ||
|
||||
!Array.isArray(data[CONF_ELEMENTS])
|
||||
) {
|
||||
@@ -511,8 +1052,7 @@ const upgradePTZElementsToLive = function (): (data: unknown) => boolean {
|
||||
if (element['type'] === 'custom:advanced-camera-card-ptz') {
|
||||
movePTZ(element);
|
||||
} else if (
|
||||
(element['type'] === 'conditional' ||
|
||||
element['type'] === 'custom:advanced-camera-card-conditional') &&
|
||||
isConditionalElementType(element['type']) &&
|
||||
Array.isArray(element['elements'])
|
||||
) {
|
||||
const newConditionalElements = processElements(element['elements']);
|
||||
@@ -547,7 +1087,7 @@ const upgradePTZElementsToLive = function (): (data: unknown) => boolean {
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2385
|
||||
// See: https://github.com/AlexxIT/WebRTC/blob/master/custom_components/webrtc/www/webrtc-camera.js
|
||||
const ptzIncorrectDataToWebRTCDataTransform = (data: unknown): unknown => {
|
||||
if (typeof data !== 'object' || !data) {
|
||||
if (!isRecord(data)) {
|
||||
return undefined;
|
||||
}
|
||||
let modified = false;
|
||||
@@ -649,7 +1189,7 @@ const ptzActionsToCamerasGlobalTransform = (data: unknown): unknown => {
|
||||
};
|
||||
|
||||
const ptzControlSettingsTransform = (data: unknown): unknown => {
|
||||
if (typeof data !== 'object' || !data) {
|
||||
if (!isRecord(data)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -680,7 +1220,7 @@ const ptzControlSettingsTransform = (data: unknown): unknown => {
|
||||
};
|
||||
|
||||
const titleControlTransform = (data: unknown): unknown => {
|
||||
if (typeof data !== 'object' || !data || typeof data['mode'] !== 'string') {
|
||||
if (!isRecord(data) || typeof data['mode'] !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (data['mode'] === 'none') {
|
||||
@@ -801,7 +1341,7 @@ const frigateCardToAdvancedCameraCardTransform = (
|
||||
* @returns `true` if the node was modified.
|
||||
*/
|
||||
const microphoneConnectedToCallTransform = (data: unknown): boolean => {
|
||||
if (typeof data !== 'object' || !data || data['condition'] !== 'microphone') {
|
||||
if (!isRecord(data) || data['condition'] !== 'microphone') {
|
||||
return false;
|
||||
}
|
||||
const connected = data['connected'];
|
||||
@@ -858,7 +1398,7 @@ const substreamActionsUnifyTransform = (data: RawAdvancedCameraCardConfig): bool
|
||||
};
|
||||
|
||||
const frigateCardToAdvancedCameraCardStyleTransform = (data: unknown): unknown => {
|
||||
if (typeof data !== 'object' || !data || Array.isArray(data)) {
|
||||
if (!isRecord(data) || Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -885,7 +1425,7 @@ const frigateCardToAdvancedCameraCardStyleTransform = (data: unknown): unknown =
|
||||
// refuse to overwrite it -- but we still drop the legacy `events` (otherwise
|
||||
// it would fail the new schema, which expects objects).
|
||||
const triggersEventsToMediaEventsTransform = (triggers: unknown): unknown => {
|
||||
if (typeof triggers !== 'object' || !triggers) {
|
||||
if (!isRecord(triggers)) {
|
||||
return undefined;
|
||||
}
|
||||
const events = triggers['events'];
|
||||
@@ -904,7 +1444,7 @@ const UPGRADES = [
|
||||
// v5.2.0 -> v6.0.0
|
||||
(data: unknown): boolean => {
|
||||
return upgradeObjectRecursively(serviceDataToDataTransform)(
|
||||
typeof data === 'object' && data ? <RawAdvancedCameraCardConfig>data : {},
|
||||
isRecord(data) ? data : {},
|
||||
);
|
||||
},
|
||||
upgradePTZElementsToLive(),
|
||||
@@ -1038,7 +1578,7 @@ const UPGRADES = [
|
||||
// different.
|
||||
(data: unknown): boolean => {
|
||||
return upgradeObjectRecursively(callServiceToPerformActionTransform)(
|
||||
typeof data === 'object' && data ? (data as RawAdvancedCameraCardConfig) : {},
|
||||
isRecord(data) ? data : {},
|
||||
);
|
||||
},
|
||||
upgradeMoveToWithOverrides('dimensions.max_height', CONF_DIMENSIONS_HEIGHT),
|
||||
@@ -1052,7 +1592,7 @@ const UPGRADES = [
|
||||
// v7.0.0+
|
||||
(data: unknown): boolean => {
|
||||
return upgradeObjectRecursively(frigateCardToAdvancedCameraCardTransform)(
|
||||
typeof data === 'object' && data ? (data as RawAdvancedCameraCardConfig) : {},
|
||||
isRecord(data) ? data : {},
|
||||
);
|
||||
},
|
||||
upgradeWithOverrides(
|
||||
@@ -1089,7 +1629,7 @@ const UPGRADES = [
|
||||
upgradeWithOverrides('ptz', ptzIncorrectDataToWebRTCDataTransform),
|
||||
),
|
||||
|
||||
// microphone.connected → call condition migration. Conditions live under
|
||||
// microphone.connected -> call condition migration. Conditions live under
|
||||
// overrides, elements, and automations.
|
||||
upgradeArrayOfObjects(CONF_OVERRIDES, (override) =>
|
||||
upgradeObjectRecursively(microphoneConnectedToCallTransform)(override),
|
||||
@@ -1110,11 +1650,11 @@ const UPGRADES = [
|
||||
// automations, view-action handlers, etc.).
|
||||
(data: unknown): boolean => {
|
||||
return upgradeObjectRecursively(substreamActionsUnifyTransform)(
|
||||
typeof data === 'object' && data ? (data as RawAdvancedCameraCardConfig) : {},
|
||||
isRecord(data) ? data : {},
|
||||
);
|
||||
},
|
||||
|
||||
// Legacy `triggers.events: string[]` → `triggers.media_events`. Targets the
|
||||
// Legacy `triggers.events: string[]` -> `triggers.media_events`. Targets the
|
||||
// two known places a camera config lives: `cameras_global` and `cameras[]`.
|
||||
// Mirrors the PTZ rename migration above.
|
||||
upgradeWithOverrides('cameras_global.triggers', triggersEventsToMediaEventsTransform),
|
||||
@@ -1122,4 +1662,31 @@ const UPGRADES = [
|
||||
CONF_CAMERAS,
|
||||
upgradeWithOverrides('triggers', triggersEventsToMediaEventsTransform),
|
||||
),
|
||||
|
||||
// Convert `actions_not` automations to an `if`/`then`/`else` action (or record
|
||||
// the unfaithful ones as failures). Runs before the promotion below, which
|
||||
// then skips the converted ones (they gain `triggers:`).
|
||||
migrateActionsNotTransform,
|
||||
|
||||
// Promote automation `conditions:` into HA-native `triggers:`. Runs last so it
|
||||
// sees conditions in their final, fully-migrated form.
|
||||
upgradeArrayOfObjects(CONF_AUTOMATIONS, promoteConditionsToTriggersTransform),
|
||||
|
||||
// Drop the now-invalid trigger-only conditions (incl. the removed `config`
|
||||
// condition) from overrides/elements, dropping any entry left ungated.
|
||||
stripTriggerOnlyConditionsFromOverridesElementsTransform,
|
||||
|
||||
// Rewrite legacy nested trigger template paths to the top-level `trigger.*`.
|
||||
(data: unknown): boolean => {
|
||||
return upgradeObjectRecursively(migrateTriggerTemplatePathsTransform)(
|
||||
isRecord(data) ? data : {},
|
||||
);
|
||||
},
|
||||
|
||||
// Rewrite the retired ambient `advanced_camera_card.*` namespace to `acc.*`.
|
||||
(data: unknown): boolean => {
|
||||
return upgradeObjectRecursively(migrateAmbientTemplateNamespaceTransform)(
|
||||
isRecord(data) ? data : {},
|
||||
);
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { callServiceActionSchema } from './call-service';
|
||||
import { customActionSchema } from './custom';
|
||||
import { moreInfoActionSchema } from './more-info';
|
||||
import { navigateActionSchema } from './navigate';
|
||||
import { noneActionSchema } from './none';
|
||||
import { performActionActionSchema } from './perform-action';
|
||||
import { toggleActionSchema } from './toggle';
|
||||
import { urlActionSchema } from './url';
|
||||
|
||||
export const stockActionSchema = z.union([
|
||||
callServiceActionSchema,
|
||||
customActionSchema,
|
||||
moreInfoActionSchema,
|
||||
navigateActionSchema,
|
||||
noneActionSchema,
|
||||
performActionActionSchema,
|
||||
toggleActionSchema,
|
||||
urlActionSchema,
|
||||
]);
|
||||
@@ -1,7 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { linkSchema } from '../common/link';
|
||||
import { preprocessToArray } from '../common/preprocess-to-array';
|
||||
import { severitySchema } from '../common/severity';
|
||||
import { statusBarItemBaseSchema } from '../common/status-bar';
|
||||
import { Condition, conditionSchema } from '../condition-trigger/conditions/types';
|
||||
import { actionBaseSchema } from './base';
|
||||
import { advancedCameraCardCustomActionsBaseSchema } from './custom/base';
|
||||
import { callAnswerActionConfigSchema } from './custom/call-answer';
|
||||
import { callEndActionConfigSchema } from './custom/call-end';
|
||||
@@ -22,13 +25,20 @@ import { sleepActionConfigSchema } from './custom/sleep';
|
||||
import { substreamOffActionConfigSchema } from './custom/substream-off';
|
||||
import { substreamOnActionConfigSchema } from './custom/substream-on';
|
||||
import { viewActionConfigSchema } from './custom/view';
|
||||
import { stockActionSchema } from './stock/types';
|
||||
import { callServiceActionSchema } from './stock/call-service';
|
||||
import { customActionSchema } from './stock/custom';
|
||||
import { moreInfoActionSchema } from './stock/more-info';
|
||||
import { navigateActionSchema } from './stock/navigate';
|
||||
import { noneActionSchema } from './stock/none';
|
||||
import { performActionActionSchema } from './stock/perform-action';
|
||||
import { toggleActionSchema } from './stock/toggle';
|
||||
import { urlActionSchema } from './stock/url';
|
||||
|
||||
// ============================================================================
|
||||
// Notification and Status Bar action schemas are co-located here because their
|
||||
// content schemas reference actionConfigSchema (creating a circular dep).
|
||||
// Each uses z.lazy + a manual type annotation to break the cycle and preserve
|
||||
// correct type inference.
|
||||
// The Notification, Status Bar, and `if`/`then`/`else` action schemas are
|
||||
// co-located here because their content references actionConfigSchema (creating
|
||||
// a circular dep). Each uses z.lazy + a manual type annotation to break the
|
||||
// cycle and preserve correct type inference.
|
||||
// See: https://zod.dev/?id=recursive-types
|
||||
// ============================================================================
|
||||
|
||||
@@ -88,9 +98,41 @@ export type AdvancedCameraCardCustomActionConfig = z.infer<
|
||||
typeof advancedCameraCardCustomActionSchema
|
||||
>;
|
||||
|
||||
// HA `if`/`then`/`else` script action: unlike most actions this has no `action:`
|
||||
// key, and is identified by the presence of an `if` key. `then`/`else` reference
|
||||
// actionConfigSchema recursively, so it uses z.lazy to break the cycle.
|
||||
export type IfActionConfig = z.infer<typeof actionBaseSchema> & {
|
||||
if: Condition[];
|
||||
then: ActionConfig[];
|
||||
else?: ActionConfig[];
|
||||
};
|
||||
const ifActionConfigSchema: z.ZodSchema<IfActionConfig> = actionBaseSchema.extend({
|
||||
if: preprocessToArray(conditionSchema.array()),
|
||||
then: preprocessToArray(z.lazy(() => actionConfigSchema).array()),
|
||||
else: preprocessToArray(z.lazy(() => actionConfigSchema).array()).optional(),
|
||||
});
|
||||
|
||||
// The HA stock actions. Assembled here, rather than in a `stock/` file, because
|
||||
// it includes the recursive `if` action above (which must live in this module).
|
||||
const stockActionSchema = z.union([
|
||||
callServiceActionSchema,
|
||||
customActionSchema,
|
||||
ifActionConfigSchema,
|
||||
moreInfoActionSchema,
|
||||
navigateActionSchema,
|
||||
noneActionSchema,
|
||||
performActionActionSchema,
|
||||
toggleActionSchema,
|
||||
urlActionSchema,
|
||||
]);
|
||||
|
||||
// The specific custom schemas must come *before* the stock union: the latter
|
||||
// contains `customActionSchema`, a loose `action: fire-dom-event` catch-all
|
||||
// that would otherwise match (and shadow) every specific custom action,
|
||||
// dropping their defaults and validation.
|
||||
export const actionConfigSchema = z.union([
|
||||
stockActionSchema,
|
||||
advancedCameraCardCustomActionSchema,
|
||||
stockActionSchema,
|
||||
]);
|
||||
export type ActionConfig = z.infer<typeof actionConfigSchema>;
|
||||
|
||||
|
||||
@@ -1,20 +1,42 @@
|
||||
import { z } from 'zod';
|
||||
import { isRecord } from '../../utils/basic';
|
||||
import { actionConfigSchema } from './actions/types';
|
||||
import { advancedCameraCardConditionSchema } from './conditions/types';
|
||||
import { preprocessToArray } from './common/preprocess-to-array';
|
||||
import { conditionSchema } from './condition-trigger/conditions/types';
|
||||
import { triggerSchema } from './condition-trigger/triggers/types';
|
||||
|
||||
const automationActionsSchema = actionConfigSchema.array();
|
||||
export type AutomationActions = z.infer<typeof automationActionsSchema>;
|
||||
|
||||
const automationSchema = z
|
||||
.object({
|
||||
conditions: advancedCameraCardConditionSchema.array(),
|
||||
actions: automationActionsSchema.optional(),
|
||||
actions_not: automationActionsSchema.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => data.actions?.length || data.actions_not?.length,
|
||||
'Automations must include at least one action',
|
||||
);
|
||||
// Accept Home Assistant's singular `trigger`/`condition`/`action` keys,
|
||||
// renaming each to the plural form used here (mirroring HA's `cv.renamed`).
|
||||
// Conservative: a singular key is renamed only when its plural is absent.
|
||||
const renameSingularKeys = (value: unknown): unknown => {
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
const renamed = { ...value };
|
||||
for (const [singular, plural] of [
|
||||
['trigger', 'triggers'],
|
||||
['condition', 'conditions'],
|
||||
['action', 'actions'],
|
||||
] as const) {
|
||||
if (singular in renamed && !(plural in renamed)) {
|
||||
renamed[plural] = renamed[singular];
|
||||
delete renamed[singular];
|
||||
}
|
||||
}
|
||||
return renamed;
|
||||
};
|
||||
|
||||
const automationSchema = z.preprocess(
|
||||
renameSingularKeys,
|
||||
z.object({
|
||||
triggers: preprocessToArray(triggerSchema.array().min(1)),
|
||||
conditions: preprocessToArray(conditionSchema.array()).optional(),
|
||||
actions: preprocessToArray(automationActionsSchema),
|
||||
}),
|
||||
);
|
||||
export type Automation = z.infer<typeof automationSchema>;
|
||||
|
||||
export const automationsSchema = automationSchema.array();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Accept a single item where a list is expected and normalise it to a list,
|
||||
// mirroring Home Assistant's `cv.ensure_list`. The schema output is always the
|
||||
// list, so the rest of the code only ever sees the canonical form.
|
||||
export const preprocessToArray = <T extends z.ZodTypeAny>(arraySchema: T) =>
|
||||
z.preprocess(
|
||||
(value) => (value === undefined || Array.isArray(value) ? value : [value]),
|
||||
arraySchema,
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// A value that may be a single string or a list of strings — common across HA
|
||||
// condition/trigger fields (e.g. `state`, `to`, `entity_id`).
|
||||
export const stringOrArray = z.string().or(z.string().array());
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// A Home Assistant time-period value (e.g. a condition/trigger `for:`),
|
||||
// matching HA's `cv.positive_time_period_template`: a number of seconds, an
|
||||
// `HH:MM`/ `HH:MM:SS` string, or a {days, hours, minutes, seconds,
|
||||
// milliseconds} dict. The whole value, or any individual dict field, may be a
|
||||
// template string rendered at evaluation time (e.g. `minutes: "{{
|
||||
// states('input_number.delay') | int }}"`).
|
||||
// https://www.home-assistant.io/docs/scripts/conditions/ (the `for` option)
|
||||
const numberOrTemplate = z.number().or(z.string());
|
||||
export const timePeriodSchema = z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.object({
|
||||
days: numberOrTemplate.optional(),
|
||||
hours: numberOrTemplate.optional(),
|
||||
minutes: numberOrTemplate.optional(),
|
||||
seconds: numberOrTemplate.optional(),
|
||||
milliseconds: numberOrTemplate.optional(),
|
||||
}),
|
||||
]);
|
||||
export type TimePeriod = z.infer<typeof timePeriodSchema>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const callBaseSchema = z.object({
|
||||
call: z.boolean().optional(),
|
||||
});
|
||||
export type CallBase = z.infer<typeof callBaseSchema>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const cameraBaseSchema = z.object({
|
||||
// Matched against the selected camera: omitted matches any (a camera is
|
||||
// selected), a list matches one of those cameras, and `[]` matches none (no
|
||||
// camera selected).
|
||||
cameras: z.string().array().optional(),
|
||||
});
|
||||
export type CameraBase = z.infer<typeof cameraBaseSchema>;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const configBaseSchema = z.object({
|
||||
paths: z.string().array().optional(),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { viewDisplayModeSchema } from '../../common/display';
|
||||
|
||||
export const displayModeBaseSchema = z.object({
|
||||
display_mode: viewDisplayModeSchema.optional(),
|
||||
});
|
||||
export type DisplayModeBase = z.infer<typeof displayModeBaseSchema>;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// HA accepts a boolean or a template string (rendered at runtime) for `enabled`
|
||||
// (`vol.Any(boolean, template)`). Shared by the condition and trigger bases.
|
||||
export const enabledSchema = z.boolean().or(z.string()).optional();
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const expandBaseSchema = z.object({
|
||||
expand: z.boolean().optional(),
|
||||
});
|
||||
export type ExpandBase = z.infer<typeof expandBaseSchema>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const fullscreenBaseSchema = z.object({
|
||||
fullscreen: z.boolean().optional(),
|
||||
});
|
||||
export type FullscreenBase = z.infer<typeof fullscreenBaseSchema>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const initializedBaseSchema = z.object({});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const interactionBaseSchema = z.object({
|
||||
interaction: z.boolean().optional(),
|
||||
});
|
||||
export type InteractionBase = z.infer<typeof interactionBaseSchema>;
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const keyConditionSchema = z.object({
|
||||
condition: z.literal('key'),
|
||||
key: z.string(),
|
||||
export const keyBaseSchema = z.object({
|
||||
key: z.string().optional(),
|
||||
state: z.enum(['down', 'up']).optional(),
|
||||
ctrl: z.boolean().optional(),
|
||||
shift: z.boolean().optional(),
|
||||
alt: z.boolean().optional(),
|
||||
meta: z.boolean().optional(),
|
||||
});
|
||||
export type KeyBase = z.infer<typeof keyBaseSchema>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const mediaLoadedBaseSchema = z.object({
|
||||
media_loaded: z.boolean().optional(),
|
||||
});
|
||||
export type MediaLoadedBase = z.infer<typeof mediaLoadedBaseSchema>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const microphoneBaseSchema = z.object({
|
||||
muted: z.boolean().optional(),
|
||||
});
|
||||
export type MicrophoneBase = z.infer<typeof microphoneBaseSchema>;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// HA `NUMERIC_STATE_THRESHOLD_SCHEMA`: a number, or an entity id whose state
|
||||
// supplies the threshold value (e.g. compare against an
|
||||
// input_number/number/sensor/zone in HA).
|
||||
const thresholdSchema = z.number().or(z.string());
|
||||
|
||||
// Fields shared by the `numeric_state` condition AND trigger.
|
||||
// (`entity`/`entity_id` live in the per-context entity bases; the
|
||||
// ≥1-of-`above`/`below` rule is applied by each consumer, and the trigger adds
|
||||
// `for`.)
|
||||
export const numericStateBaseSchema = z.object({
|
||||
// Common to both of Home Assistant's dialects (automations & picture
|
||||
// elements):
|
||||
above: thresholdSchema.optional(),
|
||||
below: thresholdSchema.optional(),
|
||||
attribute: z.string().optional(),
|
||||
|
||||
// HA automation field (not present in the picture-elements dialect), but
|
||||
// respected in both usecases in this card:
|
||||
// https://www.home-assistant.io/docs/scripts/conditions/#numeric-state-condition
|
||||
value_template: z.string().optional(),
|
||||
});
|
||||
export type NumericStateBase = z.infer<typeof numericStateBaseSchema>;
|
||||
|
||||
// HA requires at least one of `above`/`below` on a numeric_state condition or
|
||||
// trigger. Shared as a predicate because `.shape` (used to merge this base into
|
||||
// each schema) drops refinements, so the rule is re-applied by each consumer.
|
||||
export const hasAboveOrBelow = (data: NumericStateBase): boolean =>
|
||||
data.above !== undefined || data.below !== undefined;
|
||||
|
||||
// HA's numeric_state TRIGGER rejects an impossible band where a literal `above`
|
||||
// exceeds a literal `below`: the value can never be both, so the trigger could
|
||||
// never fire (HA `validate_above_below`).
|
||||
export const aboveNotGreaterThanBelow = (data: NumericStateBase): boolean =>
|
||||
typeof data.above !== 'number' ||
|
||||
typeof data.below !== 'number' ||
|
||||
data.above <= data.below;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const screenBaseSchema = z.object({
|
||||
// Optional, as in HA (a screen condition without a query simply never matches).
|
||||
media_query: z.string().optional(),
|
||||
});
|
||||
export type ScreenBase = z.infer<typeof screenBaseSchema>;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
import { timePeriodSchema } from '../../common/time-period';
|
||||
|
||||
// Fields shared by the `state` condition AND trigger.
|
||||
export const stateBaseSchema = z.object({
|
||||
// Match against an entity attribute instead of its state.
|
||||
attribute: z.string().optional(),
|
||||
|
||||
// the match must hold for at least this time period.
|
||||
for: timePeriodSchema.optional(),
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const triggeredBaseSchema = z.object({
|
||||
// Matched against the set of currently-triggered cameras: omitted matches any
|
||||
// (the set is non-empty), a list matches when one of those cameras is in the
|
||||
// set, and `[]` matches when the set is empty (no camera triggered).
|
||||
triggered: z.string().array().optional(),
|
||||
});
|
||||
export type TriggeredBase = z.infer<typeof triggeredBaseSchema>;
|
||||
+1
-2
@@ -1,8 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { regexSchema } from '../../common/regex';
|
||||
|
||||
export const userAgentConditionSchema = z.object({
|
||||
condition: z.literal('user_agent'),
|
||||
export const userAgentBaseSchema = z.object({
|
||||
user_agent: z.string().optional(),
|
||||
user_agent_re: regexSchema.optional(),
|
||||
casting: z.boolean().optional(),
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const userBaseSchema = z.object({
|
||||
// Optional, as in HA (no users simply matches no one).
|
||||
users: z.string().array().optional(),
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const viewBaseSchema = z.object({
|
||||
views: z.string().array().optional(),
|
||||
});
|
||||
export type ViewBase = z.infer<typeof viewBaseSchema>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { enabledSchema } from '../common/enabled';
|
||||
|
||||
export const conditionBaseSchema = z.object({
|
||||
enabled: enabledSchema,
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { callBaseSchema } from '../../common/call';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
export const callConditionSchema = callBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('call'),
|
||||
call: z.boolean(),
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import { cameraBaseSchema } from '../../common/camera';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
export const cameraConditionSchema = cameraBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('camera'),
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
import { viewDisplayModeSchema } from '../../../common/display';
|
||||
import { displayModeBaseSchema } from '../../common/display-mode';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
export const displayModeConditionSchema = displayModeBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('display_mode'),
|
||||
display_mode: viewDisplayModeSchema,
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { expandBaseSchema } from '../../common/expand';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
export const expandConditionSchema = expandBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('expand'),
|
||||
expand: z.boolean(),
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { fullscreenBaseSchema } from '../../common/fullscreen';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
export const fullscreenConditionSchema = fullscreenBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('fullscreen'),
|
||||
fullscreen: z.boolean(),
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import { initializedBaseSchema } from '../../common/initialized';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
export const initializedConditionSchema = initializedBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('initialized'),
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { interactionBaseSchema } from '../../common/interaction';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
export const interactionConditionSchema = interactionBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('interaction'),
|
||||
interaction: z.boolean(),
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { keyBaseSchema } from '../../common/key';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
export const keyConditionSchema = keyBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('key'),
|
||||
key: z.string(),
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { mediaLoadedBaseSchema } from '../../common/media-loaded';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
export const mediaLoadedConditionSchema = mediaLoadedBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('media_loaded'),
|
||||
media_loaded: z.boolean(),
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { microphoneBaseSchema } from '../../common/microphone';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
export const microphoneConditionSchema = microphoneBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('microphone'),
|
||||
muted: z.boolean(),
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import { triggeredBaseSchema } from '../../common/triggered';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
export const triggeredConditionSchema = triggeredBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('triggered'),
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
import { userAgentBaseSchema } from '../../common/user-agent';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
export const userAgentConditionSchema = userAgentBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('user_agent'),
|
||||
})
|
||||
// With no field set a `user_agent` condition matches every user agent (always
|
||||
// true), which is useless; require at least one constraint.
|
||||
.refine(
|
||||
(data) =>
|
||||
data.user_agent !== undefined ||
|
||||
data.user_agent_re !== undefined ||
|
||||
data.casting !== undefined ||
|
||||
data.companion !== undefined,
|
||||
'A `user_agent` condition requires at least one of `user_agent`/`user_agent_re`/`casting`/`companion`',
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { viewBaseSchema } from '../../common/view';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
export const viewConditionSchema = viewBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('view'),
|
||||
views: z.string().array().min(1),
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
import { stringOrArray } from '../../../common/string-or-array';
|
||||
|
||||
// Shared by the stock `state`/`numeric_state` conditions: the card accepts both
|
||||
// of HA's entity-field dialects (at least one of which is required). Either may
|
||||
// be a single entity or a list.
|
||||
export const entityConditionBaseSchema = z
|
||||
.object({
|
||||
// Picture-element / dashboard dialect (canonical in this card):
|
||||
// https://www.home-assistant.io/dashboards/picture-elements/#conditional-element
|
||||
entity: stringOrArray.optional(),
|
||||
|
||||
// Automation dialect (accepted alias):
|
||||
// https://www.home-assistant.io/docs/scripts/conditions/
|
||||
entity_id: stringOrArray.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => data.entity !== undefined || data.entity_id !== undefined,
|
||||
'A condition requires `entity` (or its `entity_id` alias)',
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
import { hasAboveOrBelow, numericStateBaseSchema } from '../../common/numeric-state';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
import { entityConditionBaseSchema } from './entity-base';
|
||||
|
||||
// https://www.home-assistant.io/dashboards/conditional/#numeric-state
|
||||
export const numericStateConditionSchema = entityConditionBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend(numericStateBaseSchema.shape)
|
||||
.extend({ condition: z.literal('numeric_state') })
|
||||
.refine(
|
||||
hasAboveOrBelow,
|
||||
'A numeric_state condition requires at least one of `above`/`below`',
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { screenBaseSchema } from '../../common/screen';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
// https://www.home-assistant.io/dashboards/conditional/#screen
|
||||
export const screenConditionSchema = screenBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('screen'),
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
import { stringOrArray } from '../../../common/string-or-array';
|
||||
import { stateBaseSchema } from '../../common/state';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
import { entityConditionBaseSchema } from './entity-base';
|
||||
|
||||
// https://www.home-assistant.io/dashboards/conditional/#state
|
||||
export const stateConditionSchema = entityConditionBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend(stateBaseSchema.shape)
|
||||
.extend({
|
||||
// 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.
|
||||
// https://www.home-assistant.io/dashboards/picture-elements/#conditional-element
|
||||
state_not: stringOrArray.optional(),
|
||||
|
||||
// How a list of entities is combined: `all` (the default) requires every
|
||||
// entity to match, `any` requires at least one.
|
||||
// https://www.home-assistant.io/docs/scripts/conditions/#state-condition
|
||||
match: z.enum(['all', 'any']).optional(),
|
||||
})
|
||||
// A state condition is not useful without either `state` or `state_not` to
|
||||
// test against.
|
||||
.refine(
|
||||
(data) => data.state !== undefined || data.state_not !== undefined,
|
||||
'A `state` condition requires `state` or `state_not`',
|
||||
);
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
// https://www.home-assistant.io/docs/scripts/conditions/#template-condition
|
||||
export const templateConditionSchema = z.object({
|
||||
export const templateConditionSchema = conditionBaseSchema.extend({
|
||||
condition: z.literal('template'),
|
||||
value_template: z.string(),
|
||||
});
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
import { numericStateConditionSchema } from './numeric';
|
||||
import { numericStateConditionSchema } from './numeric-state';
|
||||
import { screenConditionSchema } from './screen';
|
||||
import { stateConditionSchema } from './state';
|
||||
import { usersConditionSchema } from './users';
|
||||
import { userConditionSchema } from './user';
|
||||
|
||||
export const stockConditionSchema = z.discriminatedUnion('condition', [
|
||||
stateConditionSchema,
|
||||
numericStateConditionSchema,
|
||||
screenConditionSchema,
|
||||
usersConditionSchema,
|
||||
userConditionSchema,
|
||||
]);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { userBaseSchema } from '../../common/user';
|
||||
import { conditionBaseSchema } from '../base';
|
||||
|
||||
// https://www.home-assistant.io/dashboards/conditional/#user
|
||||
export const userConditionSchema = userBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('user'),
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { z } from 'zod';
|
||||
import { isRecord } from '../../../../utils/basic';
|
||||
import { preprocessToArray } from '../../common/preprocess-to-array';
|
||||
import { conditionBaseSchema } from './base';
|
||||
import { callConditionSchema } from './custom/call';
|
||||
import { cameraConditionSchema } from './custom/camera';
|
||||
import { displayModeConditionSchema } from './custom/display-mode';
|
||||
import { expandConditionSchema } from './custom/expand';
|
||||
import { fullscreenConditionSchema } from './custom/fullscreen';
|
||||
import { initializedConditionSchema } from './custom/initialized';
|
||||
import { interactionConditionSchema } from './custom/interaction';
|
||||
import { keyConditionSchema } from './custom/key';
|
||||
import { mediaLoadedConditionSchema } from './custom/media-loaded';
|
||||
import { microphoneConditionSchema } from './custom/microphone';
|
||||
import { triggeredConditionSchema } from './custom/triggered';
|
||||
import { userAgentConditionSchema } from './custom/user-agent';
|
||||
import { viewConditionSchema } from './custom/view';
|
||||
import { numericStateConditionSchema } from './stock/numeric-state';
|
||||
import { screenConditionSchema } from './stock/screen';
|
||||
import { stateConditionSchema } from './stock/state';
|
||||
import { templateConditionSchema } from './stock/template';
|
||||
import { userConditionSchema } from './stock/user';
|
||||
|
||||
type CompositeCondition = z.infer<typeof conditionBaseSchema> & {
|
||||
conditions: Condition[];
|
||||
};
|
||||
|
||||
// https://www.home-assistant.io/docs/scripts/conditions/#or-condition
|
||||
type OrCondition = CompositeCondition & { condition: 'or' };
|
||||
const orConditionSchema: z.ZodSchema<OrCondition> = conditionBaseSchema.extend({
|
||||
condition: z.literal('or'),
|
||||
|
||||
// HA requires the `conditions` key but allows an empty list, and accepts a
|
||||
// single condition in place of a list (`cv.ensure_list`).
|
||||
conditions: preprocessToArray(z.lazy(() => conditionSchema).array()),
|
||||
});
|
||||
|
||||
// https://www.home-assistant.io/docs/scripts/conditions/#and-condition
|
||||
type AndCondition = CompositeCondition & { condition: 'and' };
|
||||
const andConditionSchema: z.ZodSchema<AndCondition> = conditionBaseSchema.extend({
|
||||
condition: z.literal('and'),
|
||||
|
||||
// HA requires the `conditions` key but allows an empty list, and accepts a
|
||||
// single condition in place of a list (`cv.ensure_list`).
|
||||
conditions: preprocessToArray(z.lazy(() => conditionSchema).array()),
|
||||
});
|
||||
|
||||
// https://www.home-assistant.io/docs/scripts/conditions/#not-condition
|
||||
type NotCondition = CompositeCondition & { condition: 'not' };
|
||||
const notConditionSchema: z.ZodSchema<NotCondition> = conditionBaseSchema.extend({
|
||||
condition: z.literal('not'),
|
||||
|
||||
// HA requires the `conditions` key but allows an empty list, and accepts a
|
||||
// single condition in place of a list (`cv.ensure_list`).
|
||||
conditions: preprocessToArray(z.lazy(() => conditionSchema).array()),
|
||||
});
|
||||
|
||||
// Expand Home Assistant's composite shorthand to the canonical `{condition:
|
||||
// <op>, conditions: [...]}` form:
|
||||
// - `{and|or|not: [...]}` -- the boolean operator is the key.
|
||||
// - `condition: [...]` -- a list under the discriminator is an implicit AND.
|
||||
// Conservative: anything already canonical, ambiguous, or unrecognised
|
||||
// passes through for the union (or its error) to handle.
|
||||
const expandCompositeShorthand = (value: unknown): unknown => {
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value.condition)) {
|
||||
const { condition: conditions, ...rest } = value;
|
||||
return { ...rest, condition: 'and', conditions };
|
||||
}
|
||||
if ('condition' in value) {
|
||||
return value;
|
||||
}
|
||||
const present = (['and', 'or', 'not'] as const).filter((op) => op in value);
|
||||
const op = present.length === 1 ? present[0] : undefined;
|
||||
if (op === undefined) {
|
||||
return value;
|
||||
}
|
||||
const { [op]: conditions, ...rest } = value;
|
||||
return { ...rest, condition: op, conditions };
|
||||
};
|
||||
|
||||
// The raw union of all condition members. `conditionSchema` wraps this with the
|
||||
// shorthand preprocess; this is exported only for schema introspection.
|
||||
export const conditionUnion = z.union([
|
||||
// Stock conditions:
|
||||
numericStateConditionSchema,
|
||||
screenConditionSchema,
|
||||
stateConditionSchema,
|
||||
userConditionSchema,
|
||||
orConditionSchema,
|
||||
andConditionSchema,
|
||||
notConditionSchema,
|
||||
templateConditionSchema,
|
||||
|
||||
// Custom conditions:
|
||||
callConditionSchema,
|
||||
cameraConditionSchema,
|
||||
displayModeConditionSchema,
|
||||
expandConditionSchema,
|
||||
fullscreenConditionSchema,
|
||||
initializedConditionSchema,
|
||||
interactionConditionSchema,
|
||||
keyConditionSchema,
|
||||
mediaLoadedConditionSchema,
|
||||
microphoneConditionSchema,
|
||||
triggeredConditionSchema,
|
||||
userAgentConditionSchema,
|
||||
viewConditionSchema,
|
||||
]);
|
||||
|
||||
export const conditionSchema = z.preprocess(expandCompositeShorthand, conditionUnion);
|
||||
export type Condition = z.infer<typeof conditionSchema>;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import { enabledSchema } from '../common/enabled';
|
||||
|
||||
// Universal trigger fields. Current the card does not support
|
||||
// `id`/`alias`/`variables` parameters (which HA does).
|
||||
export const triggerBaseSchema = z.object({
|
||||
enabled: enabledSchema,
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { callBaseSchema } from '../../common/call';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
|
||||
export const callTriggerSchema = callBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend({ trigger: z.literal('call') });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { cameraBaseSchema } from '../../common/camera';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
|
||||
export const cameraTriggerSchema = cameraBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend({ trigger: z.literal('camera') });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { configBaseSchema } from '../../common/config';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
|
||||
export const configTriggerSchema = configBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend({ trigger: z.literal('config') });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { displayModeBaseSchema } from '../../common/display-mode';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
|
||||
export const displayModeTriggerSchema = displayModeBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend({ trigger: z.literal('display_mode') });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { expandBaseSchema } from '../../common/expand';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
|
||||
export const expandTriggerSchema = expandBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend({ trigger: z.literal('expand') });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { fullscreenBaseSchema } from '../../common/fullscreen';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
|
||||
export const fullscreenTriggerSchema = fullscreenBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend({ trigger: z.literal('fullscreen') });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { initializedBaseSchema } from '../../common/initialized';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
|
||||
export const initializedTriggerSchema = initializedBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend({ trigger: z.literal('initialized') });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { interactionBaseSchema } from '../../common/interaction';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
|
||||
export const interactionTriggerSchema = interactionBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend({ trigger: z.literal('interaction') });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { keyBaseSchema } from '../../common/key';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
|
||||
export const keyTriggerSchema = keyBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend({ trigger: z.literal('key') });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { mediaLoadedBaseSchema } from '../../common/media-loaded';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
|
||||
export const mediaLoadedTriggerSchema = mediaLoadedBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend({ trigger: z.literal('media_loaded') });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { microphoneBaseSchema } from '../../common/microphone';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
|
||||
export const microphoneTriggerSchema = microphoneBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend({ trigger: z.literal('microphone') });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { screenBaseSchema } from '../../common/screen';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
|
||||
export const screenTriggerSchema = screenBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend({ trigger: z.literal('screen') });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { triggeredBaseSchema } from '../../common/triggered';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
|
||||
export const triggeredTriggerSchema = triggeredBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend({ trigger: z.literal('triggered') });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { viewBaseSchema } from '../../common/view';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
|
||||
export const viewTriggerSchema = viewBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend({ trigger: z.literal('view') });
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from 'zod';
|
||||
import { stringOrArray } from '../../../common/string-or-array';
|
||||
|
||||
// Shared by the stock `state`/`numeric_state` conditions: the card accepts both
|
||||
// of HA's entity-field dialects (at least one of which is required).
|
||||
export const entityTriggerBaseSchema = z
|
||||
.object({
|
||||
// Automation dialect (canonical for Home Assistant triggers):
|
||||
// https://www.home-assistant.io/docs/scripts/conditions/
|
||||
entity_id: stringOrArray.optional(),
|
||||
|
||||
// Picture element dialect (accepted alias). Allowed since it is canonical
|
||||
// in Home Assistant picture-elements / dashboard dialect (but not for
|
||||
// automations). Allowed as a card-specific extension to allow consistent
|
||||
// users to choose one or the other (or both).
|
||||
entity: stringOrArray.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => data.entity !== undefined || data.entity_id !== undefined,
|
||||
'A trigger requires `entity` (or its `entity_id` alias)',
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
import { timePeriodSchema } from '../../../common/time-period';
|
||||
import {
|
||||
aboveNotGreaterThanBelow,
|
||||
hasAboveOrBelow,
|
||||
numericStateBaseSchema,
|
||||
} from '../../common/numeric-state';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
import { entityTriggerBaseSchema } from './entity-base';
|
||||
|
||||
// https://www.home-assistant.io/docs/automation/trigger/#numeric-state-trigger
|
||||
export const numericStateTriggerSchema = entityTriggerBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend(numericStateBaseSchema.shape)
|
||||
.extend({
|
||||
trigger: z.literal('numeric_state'),
|
||||
for: timePeriodSchema.optional(),
|
||||
})
|
||||
.refine(
|
||||
hasAboveOrBelow,
|
||||
'A numeric_state trigger requires at least one of `above`/`below`',
|
||||
)
|
||||
.refine(
|
||||
aboveNotGreaterThanBelow,
|
||||
'A numeric_state trigger cannot have `above` greater than `below`',
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
import { stateBaseSchema } from '../../common/state';
|
||||
import { stringOrArray } from '../../../common/string-or-array';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
import { entityTriggerBaseSchema } from './entity-base';
|
||||
|
||||
// https://www.home-assistant.io/docs/automation/trigger/#state-trigger
|
||||
export const stateTriggerSchema = entityTriggerBaseSchema
|
||||
.extend(triggerBaseSchema.shape)
|
||||
.extend(stateBaseSchema.shape)
|
||||
.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(),
|
||||
})
|
||||
// HA makes `from`/`not_from` and `to`/`not_to` mutually exclusive (vol.Exclusive).
|
||||
.refine(
|
||||
(data) => !(data.from !== undefined && data.not_from !== undefined),
|
||||
'`from` and `not_from` are mutually exclusive',
|
||||
)
|
||||
.refine(
|
||||
(data) => !(data.to !== undefined && data.not_to !== undefined),
|
||||
'`to` and `not_to` are mutually exclusive',
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { timePeriodSchema } from '../../../common/time-period';
|
||||
import { triggerBaseSchema } from '../base';
|
||||
|
||||
// https://www.home-assistant.io/docs/automation/trigger/#template-trigger
|
||||
export const templateTriggerSchema = triggerBaseSchema.extend({
|
||||
trigger: z.literal('template'),
|
||||
value_template: z.string(),
|
||||
for: timePeriodSchema.optional(),
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod';
|
||||
import { callTriggerSchema } from './custom/call';
|
||||
import { cameraTriggerSchema } from './custom/camera';
|
||||
import { configTriggerSchema } from './custom/config';
|
||||
import { displayModeTriggerSchema } from './custom/display-mode';
|
||||
import { expandTriggerSchema } from './custom/expand';
|
||||
import { fullscreenTriggerSchema } from './custom/fullscreen';
|
||||
import { initializedTriggerSchema } from './custom/initialized';
|
||||
import { interactionTriggerSchema } from './custom/interaction';
|
||||
import { keyTriggerSchema } from './custom/key';
|
||||
import { mediaLoadedTriggerSchema } from './custom/media-loaded';
|
||||
import { microphoneTriggerSchema } from './custom/microphone';
|
||||
import { screenTriggerSchema } from './custom/screen';
|
||||
import { triggeredTriggerSchema } from './custom/triggered';
|
||||
import { viewTriggerSchema } from './custom/view';
|
||||
import { numericStateTriggerSchema } from './stock/numeric-state';
|
||||
import { stateTriggerSchema } from './stock/state';
|
||||
import { templateTriggerSchema } from './stock/template';
|
||||
|
||||
export const triggerSchema = z.union([
|
||||
// Stock triggers (HA automation triggers):
|
||||
numericStateTriggerSchema,
|
||||
stateTriggerSchema,
|
||||
templateTriggerSchema,
|
||||
|
||||
// Custom triggers. Note: `screen` is an HA picture-elements condition with no
|
||||
// HA trigger, but it genuinely can change (e.g. orientation/resize) -- so it
|
||||
// is offered as a trigger.
|
||||
callTriggerSchema,
|
||||
cameraTriggerSchema,
|
||||
configTriggerSchema,
|
||||
displayModeTriggerSchema,
|
||||
expandTriggerSchema,
|
||||
fullscreenTriggerSchema,
|
||||
initializedTriggerSchema,
|
||||
interactionTriggerSchema,
|
||||
keyTriggerSchema,
|
||||
mediaLoadedTriggerSchema,
|
||||
microphoneTriggerSchema,
|
||||
screenTriggerSchema,
|
||||
triggeredTriggerSchema,
|
||||
viewTriggerSchema,
|
||||
]);
|
||||
export type Trigger = z.infer<typeof triggerSchema>;
|
||||
@@ -1,6 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const callConditionSchema = z.object({
|
||||
condition: z.literal('call'),
|
||||
call: z.boolean().optional(),
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const cameraConditionSchema = z.object({
|
||||
condition: z.literal('camera'),
|
||||
cameras: z.string().array().optional(),
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const configConditionSchema = z.object({
|
||||
condition: z.literal('config'),
|
||||
paths: z.string().array().optional(),
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { viewDisplayModeSchema } from '../../common/display';
|
||||
|
||||
export const displayModeConditionSchema = z.object({
|
||||
condition: z.literal('display_mode'),
|
||||
display_mode: viewDisplayModeSchema,
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const expandConditionSchema = z.object({
|
||||
condition: z.literal('expand'),
|
||||
expand: z.boolean(),
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const fullscreenConditionSchema = z.object({
|
||||
condition: z.literal('fullscreen'),
|
||||
fullscreen: z.boolean(),
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const initializedConditionSchema = z.object({
|
||||
condition: z.literal('initialized'),
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const interactionConditionSchema = z.object({
|
||||
condition: z.literal('interaction'),
|
||||
interaction: z.boolean(),
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const mediaLoadedConditionSchema = z.object({
|
||||
condition: z.literal('media_loaded'),
|
||||
media_loaded: z.boolean(),
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const microphoneConditionSchema = z.object({
|
||||
condition: z.literal('microphone'),
|
||||
muted: z.boolean(),
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const triggeredConditionSchema = z.object({
|
||||
condition: z.literal('triggered'),
|
||||
triggered: z.string().array(),
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const viewConditionSchema = z.object({
|
||||
condition: z.literal('view'),
|
||||
views: z.string().array().optional(),
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// https://www.home-assistant.io/dashboards/conditional/#numeric-state
|
||||
export const numericStateConditionSchema = z.object({
|
||||
condition: z.literal('numeric_state'),
|
||||
entity: z.string(),
|
||||
above: z.number().optional(),
|
||||
below: z.number().optional(),
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// https://www.home-assistant.io/dashboards/conditional/#screen
|
||||
export const screenConditionSchema = z.object({
|
||||
condition: z.literal('screen'),
|
||||
media_query: z.string(),
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// https://www.home-assistant.io/dashboards/conditional/#state
|
||||
export const stateConditionSchema = z.object({
|
||||
// If the condition is not specified, a state condition is assumed. This
|
||||
// allows the syntax to match a picture elements conditional:
|
||||
// https://www.home-assistant.io/dashboards/picture-elements/#conditional-element
|
||||
condition: z.literal('state').optional(),
|
||||
entity: z.string(),
|
||||
state: z.string().or(z.string().array()).optional(),
|
||||
state_not: z.string().or(z.string().array()).optional(),
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// https://www.home-assistant.io/dashboards/conditional/#user
|
||||
export const usersConditionSchema = z.object({
|
||||
condition: z.literal('user'),
|
||||
users: z.string().array().min(1),
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { callConditionSchema } from './custom/call';
|
||||
import { cameraConditionSchema } from './custom/camera';
|
||||
import { configConditionSchema } from './custom/config';
|
||||
import { displayModeConditionSchema } from './custom/display-mode';
|
||||
import { expandConditionSchema } from './custom/expand';
|
||||
import { fullscreenConditionSchema } from './custom/fullscreen';
|
||||
import { initializedConditionSchema } from './custom/initialized';
|
||||
import { interactionConditionSchema } from './custom/interaction';
|
||||
import { keyConditionSchema } from './custom/key';
|
||||
import { mediaLoadedConditionSchema } from './custom/media-loaded';
|
||||
import { microphoneConditionSchema } from './custom/microphone';
|
||||
import { triggeredConditionSchema } from './custom/triggered';
|
||||
import { userAgentConditionSchema } from './custom/user-agent';
|
||||
import { viewConditionSchema } from './custom/view';
|
||||
import { numericStateConditionSchema } from './stock/numeric';
|
||||
import { screenConditionSchema } from './stock/screen';
|
||||
import { stateConditionSchema } from './stock/state';
|
||||
import { templateConditionSchema } from './stock/template';
|
||||
import { usersConditionSchema } from './stock/users';
|
||||
|
||||
// https://www.home-assistant.io/docs/scripts/conditions/#or-condition
|
||||
type OrCondition = {
|
||||
condition: 'or';
|
||||
conditions: AdvancedCameraCardCondition[];
|
||||
};
|
||||
const orConditionSchema: z.ZodSchema<OrCondition> = z.object({
|
||||
condition: z.literal('or'),
|
||||
conditions: z
|
||||
.lazy(() => advancedCameraCardConditionSchema)
|
||||
.array()
|
||||
.min(1),
|
||||
});
|
||||
|
||||
// https://www.home-assistant.io/docs/scripts/conditions/#and-condition
|
||||
type AndCondition = {
|
||||
condition: 'and';
|
||||
conditions: AdvancedCameraCardCondition[];
|
||||
};
|
||||
const andConditionSchema: z.ZodSchema<AndCondition> = z.object({
|
||||
condition: z.literal('and'),
|
||||
conditions: z
|
||||
.lazy(() => advancedCameraCardConditionSchema)
|
||||
.array()
|
||||
.min(1),
|
||||
});
|
||||
|
||||
// https://www.home-assistant.io/docs/scripts/conditions/#not-condition
|
||||
type NotCondition = {
|
||||
condition: 'not';
|
||||
conditions: AdvancedCameraCardCondition[];
|
||||
};
|
||||
const notConditionSchema: z.ZodSchema<NotCondition> = z.object({
|
||||
condition: z.literal('not'),
|
||||
conditions: z
|
||||
.lazy(() => advancedCameraCardConditionSchema)
|
||||
.array()
|
||||
.min(1),
|
||||
});
|
||||
|
||||
export const advancedCameraCardConditionSchema = z.union([
|
||||
// Stock conditions:
|
||||
numericStateConditionSchema,
|
||||
screenConditionSchema,
|
||||
stateConditionSchema,
|
||||
usersConditionSchema,
|
||||
orConditionSchema,
|
||||
andConditionSchema,
|
||||
notConditionSchema,
|
||||
templateConditionSchema,
|
||||
|
||||
// Custom conditions:
|
||||
callConditionSchema,
|
||||
cameraConditionSchema,
|
||||
configConditionSchema,
|
||||
displayModeConditionSchema,
|
||||
expandConditionSchema,
|
||||
fullscreenConditionSchema,
|
||||
initializedConditionSchema,
|
||||
interactionConditionSchema,
|
||||
keyConditionSchema,
|
||||
mediaLoadedConditionSchema,
|
||||
microphoneConditionSchema,
|
||||
triggeredConditionSchema,
|
||||
userAgentConditionSchema,
|
||||
viewConditionSchema,
|
||||
]);
|
||||
export type AdvancedCameraCardCondition = z.infer<
|
||||
typeof advancedCameraCardConditionSchema
|
||||
>;
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
statusBarImageItemSchema,
|
||||
statusBarStringItemSchema,
|
||||
} from '../actions/types';
|
||||
import { stockConditionSchema } from '../conditions/stock/types';
|
||||
import { advancedCameraCardConditionSchema } from '../conditions/types';
|
||||
import { stockConditionSchema } from '../condition-trigger/conditions/stock/types';
|
||||
import { conditionSchema } from '../condition-trigger/conditions/types';
|
||||
import { menuIconSchema } from './custom/menu/icon';
|
||||
import { menuStateIconSchema } from './custom/menu/state-icon';
|
||||
import { menuSubmenuSchema } from './custom/menu/submenu';
|
||||
@@ -36,7 +36,7 @@ export const conditionalSchema = z.object({
|
||||
|
||||
const advancedCameraCardConditionalSchema = z.object({
|
||||
type: z.literal('custom:advanced-camera-card-conditional'),
|
||||
conditions: advancedCameraCardConditionSchema.array(),
|
||||
conditions: conditionSchema.array(),
|
||||
|
||||
get elements() {
|
||||
// Recursive schema.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import { advancedCameraCardConditionSchema } from './conditions/types';
|
||||
import { conditionSchema } from './condition-trigger/conditions/types';
|
||||
|
||||
const overrideSchema = z.object({
|
||||
conditions: advancedCameraCardConditionSchema.array(),
|
||||
conditions: conditionSchema.array(),
|
||||
merge: z.looseObject({}).optional(),
|
||||
set: z.looseObject({}).optional(),
|
||||
delete: z.string().array().optional(),
|
||||
|
||||
Reference in New Issue
Block a user