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:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
co-authored by Claude Opus 4.8
parent 209c873c58
commit b701366762
354 changed files with 11386 additions and 3020 deletions
+589 -22
View File
@@ -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 : {},
);
},
];