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 : {},
|
||||
);
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user