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
@@ -0,0 +1,30 @@
import { TemplateAdvancedCameraCardState } from '../../card-controller/templates/types';
import { ConditionState, ConditionStateChange } from '../conditions/types';
import { TriggerData } from './types';
// The card-state snapshot (`from_acc`/`to_acc`) surfaced by card triggers: the
// card's own camera/view/config at a point in time.
const getTriggerState = (state: ConditionState): TemplateAdvancedCameraCardState => ({
...(state.camera !== undefined && { camera: state.camera }),
...(state.view !== undefined && { view: state.view }),
...(state.config !== undefined && { config: state.config }),
});
// Assemble the `acc`-platform payload for a card trigger: its `type` plus the
// before/after card-state snapshots (each omitted when it carries nothing).
export const buildCardTriggerData = (
type: string,
stateChange?: ConditionStateChange,
): TriggerData => {
const data: TriggerData = { platform: 'acc', type };
if (!stateChange) {
return data;
}
const from = getTriggerState(stateChange.old);
const to = getTriggerState(stateChange.new);
return {
...data,
...(Object.keys(from).length && { from_acc: from }),
...(Object.keys(to).length && { to_acc: to }),
};
};
+67
View File
@@ -0,0 +1,67 @@
import { Trigger } from '../../config/schema/condition-trigger/triggers/types';
import { CallTrigger } from './triggers/call';
import { CameraTrigger } from './triggers/camera';
import { ConfigTrigger } from './triggers/config';
import { DisplayModeTrigger } from './triggers/display-mode';
import { ExpandTrigger } from './triggers/expand';
import { FullscreenTrigger } from './triggers/fullscreen';
import { InitializedTrigger } from './triggers/initialized';
import { InteractionTrigger } from './triggers/interaction';
import { KeyTrigger } from './triggers/key';
import { MediaLoadedTrigger } from './triggers/media-loaded';
import { MicrophoneTrigger } from './triggers/microphone';
import { NumericStateTrigger } from './triggers/numeric-state';
import { ScreenTrigger } from './triggers/screen';
import { StateTrigger } from './triggers/state';
import { TemplateTrigger } from './triggers/template';
import { TriggeredTrigger } from './triggers/triggered';
import { TriggerEvaluator, TriggerEvaluatorContext } from './triggers/types';
import { ViewTrigger } from './triggers/view';
export const createTriggerEvaluator = (
trigger: Trigger,
context: TriggerEvaluatorContext,
): TriggerEvaluator => {
switch (trigger.trigger) {
// Stock HA triggers: read `hass.states` and emit HA `State` payloads.
case 'state':
return new StateTrigger(trigger, context);
case 'numeric_state':
return new NumericStateTrigger(trigger, context);
case 'template':
return new TemplateTrigger(trigger, context);
// `screen` watches window.matchMedia.
case 'screen':
return new ScreenTrigger(trigger);
// Card-state triggers: watch a field of `ConditionState`, firing on any
// change (omit the value) or when the change passes the matching condition.
case 'call':
return new CallTrigger(trigger, context);
case 'camera':
return new CameraTrigger(trigger, context);
case 'config':
return new ConfigTrigger(trigger, context);
case 'display_mode':
return new DisplayModeTrigger(trigger, context);
case 'expand':
return new ExpandTrigger(trigger, context);
case 'fullscreen':
return new FullscreenTrigger(trigger, context);
case 'initialized':
return new InitializedTrigger(trigger, context);
case 'interaction':
return new InteractionTrigger(trigger, context);
case 'key':
return new KeyTrigger(trigger, context);
case 'media_loaded':
return new MediaLoadedTrigger(trigger, context);
case 'microphone':
return new MicrophoneTrigger(trigger, context);
case 'triggered':
return new TriggeredTrigger(trigger, context);
case 'view':
return new ViewTrigger(trigger, context);
}
};
+79
View File
@@ -0,0 +1,79 @@
import { TemplateRenderer } from '../../card-controller/templates';
import { Trigger } from '../../config/schema/condition-trigger/triggers/types';
import { isEnabled } from '../common/is-enabled';
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
import { createTriggerEvaluator } from './factory';
import {
TriggerCallback,
TriggerEvaluator,
TriggerEvaluatorContext,
} from './triggers/types';
import { TriggerData } from './types';
// A trigger evaluator paired with its config, so `enabled` can be re-checked
// against the config each time the evaluator triggers.
interface ManagedTrigger {
config: Trigger;
evaluator: TriggerEvaluator;
}
/**
* Orchestrates an array of triggers (e.g. for one automation) and notifies
* listeners whenever ANY of them triggers (the top-level triggers list is an
* implicit OR). This is the push-based sibling of `ConditionsManager`.
*/
export class TriggersManager {
private _context: TriggerEvaluatorContext;
private _triggers: ManagedTrigger[];
private _listeners: TriggerCallback[] = [];
constructor(
triggers: Trigger[],
stateManager: ConditionStateManagerReadonlyInterface,
) {
this._context = { stateManager, templateRenderer: new TemplateRenderer() };
this._triggers = triggers.map((config) => ({
config,
evaluator: createTriggerEvaluator(config, this._context),
}));
// `enabled` is a live per-trigger gate (re-evaluated each time), UNLIKE
// HA's once-at-attach: a deliberate deviation to allow dynamic triggering.
this._triggers.forEach(({ config, evaluator }) =>
evaluator.subscribe((data) => {
if (
isEnabled(
this._context.templateRenderer,
config.enabled,
this._context.stateManager.getState(),
// Fail closed: with no hass the `enabled` template cannot be
// evaluated, so the trigger does not fire.
false,
)
) {
this._callListeners(data);
}
}),
);
}
public destroy(): void {
this._triggers.forEach(({ evaluator }) => evaluator.destroy());
this._triggers = [];
this._listeners = [];
}
public addListener(listener: TriggerCallback): void {
if (!this._listeners.includes(listener)) {
this._listeners.push(listener);
}
}
public removeListener(listener: TriggerCallback): void {
this._listeners = this._listeners.filter((l) => l !== listener);
}
private _callListeners = (data: TriggerData): void => {
this._listeners.forEach((listener) => listener(data));
};
}
@@ -0,0 +1,9 @@
import { ConditionState } from '../../conditions/types';
import { ConditionStateTriggerBase } from './condition-state-base';
import { TriggerOfType } from './types';
export class CallTrigger extends ConditionStateTriggerBase<TriggerOfType<'call'>> {
protected _getValue(state: ConditionState): unknown {
return state.call ?? false;
}
}
@@ -0,0 +1,11 @@
import { ConditionState } from '../../conditions/types';
import { ConditionStateTriggerBase } from './condition-state-base';
import { TriggerOfType } from './types';
// Triggers when the selected camera changes: to one of `cameras` if listed, to
// no camera if `cameras` is `[]`, or on any change if `cameras` is omitted.
export class CameraTrigger extends ConditionStateTriggerBase<TriggerOfType<'camera'>> {
protected _getValue(state: ConditionState): unknown {
return state.camera;
}
}
@@ -0,0 +1,54 @@
import { isEqual } from 'lodash-es';
import { Trigger } from '../../../config/schema/condition-trigger/triggers/types';
import { ConditionEvaluator } from '../../conditions/conditions/types';
import { createConditionEvaluatorForTrigger } from '../../conditions/factory';
import { ConditionState, ConditionStateChange } from '../../conditions/types';
import { buildCardTriggerData } from '../build-trigger-data';
import { TriggerCallback, TriggerEvaluator, TriggerEvaluatorContext } from './types';
// A trigger driven by `ConditionState` changes: subscribe to the state manager,
// fire when the watched value (`_getValue`) changes and the new state passes
// the trigger's condition, and emit the `acc` payload. The condition is the
// matching condition reused as a point-in-time predicate -- so a trigger and
// its condition share one definition of meaning. A trigger with no value (any
// change), or no matching condition (`config`), has no condition to pass.
export abstract class ConditionStateTriggerBase<T extends Trigger>
implements TriggerEvaluator
{
protected _trigger: T;
protected _context: TriggerEvaluatorContext;
private _callback: TriggerCallback | null = null;
private _condition: ConditionEvaluator | null;
constructor(trigger: T, context: TriggerEvaluatorContext) {
this._trigger = trigger;
this._context = context;
// The condition the trigger checks each change against.
this._condition = createConditionEvaluatorForTrigger(trigger);
}
public subscribe(callback: TriggerCallback): void {
this._callback = callback;
this._context.stateManager.addListener(this._handler);
}
public destroy(): void {
this._context.stateManager.removeListener(this._handler);
this._callback = null;
}
private _handler = (change: ConditionStateChange): void => {
if (isEqual(this._getValue(change.old), this._getValue(change.new))) {
return;
}
if (this._condition && !this._condition.evaluate(change.new).result) {
return;
}
this._callback?.(buildCardTriggerData(this._trigger.trigger, change));
};
// The slice of state this trigger watches; it fires only when this changes.
protected abstract _getValue(state: ConditionState): unknown;
}
@@ -0,0 +1,21 @@
import { getConfigValue } from '../../../config/management';
import { ConditionState } from '../../conditions/types';
import { ConditionStateTriggerBase } from './condition-state-base';
import { TriggerOfType } from './types';
// Triggers when the card configuration changes. With `paths`, only a change to
// one of those config paths triggers; without `paths`, any config change does.
// `config` is trigger-only (it has no matching condition), so the watched value
// is the whole of its behavior.
export class ConfigTrigger extends ConditionStateTriggerBase<TriggerOfType<'config'>> {
protected _getValue(state: ConditionState): unknown {
const config = state.config;
if (!config) {
return null;
}
// The watched value: detecting a change in these path values *is* the
// `paths` filter. Without `paths`, the whole config is watched.
const paths = this._trigger.paths;
return paths?.length ? paths.map((path) => getConfigValue(config, path)) : config;
}
}
@@ -0,0 +1,11 @@
import { ConditionState } from '../../conditions/types';
import { ConditionStateTriggerBase } from './condition-state-base';
import { TriggerOfType } from './types';
export class DisplayModeTrigger extends ConditionStateTriggerBase<
TriggerOfType<'display_mode'>
> {
protected _getValue(state: ConditionState): unknown {
return state.displayMode;
}
}
@@ -0,0 +1,128 @@
import { HassEntity } from 'home-assistant-js-websocket';
import { isEqual } from 'lodash-es';
import { arrayify } from '../../../utils/basic';
import { Timer } from '../../../utils/timer';
import { renderTimePeriodToSeconds } from '../../common/time-period';
import { ConditionStateChange } from '../../conditions/types';
import {
TriggerCallback,
TriggerEvaluator,
TriggerEvaluatorContext,
TriggerOfType,
} from './types';
// Shared scaffolding for the stock entity triggers (`state` and
// `numeric_state`): per-`entity`/`entity_id` fan-out, the `for:` hold (one
// Timer per entity), and the HA-faithful trigger payload. Subclasses implement
// only the per-entity decision (`_processEntity`) and their `platform`.
export abstract class EntityStateTriggerBase<
T extends TriggerOfType<'state'> | TriggerOfType<'numeric_state'>,
> implements TriggerEvaluator
{
protected _trigger: T;
protected _context: TriggerEvaluatorContext;
private _callback: TriggerCallback | null = null;
// A `for:` hold per entity: a list can be holding several independently.
private _forTimers = new Map<string, Timer>();
// The `trigger.platform` value reported in the trigger payload.
protected abstract readonly _platform: string;
constructor(trigger: T, context: TriggerEvaluatorContext) {
this._trigger = trigger;
this._context = context;
}
public subscribe(callback: TriggerCallback): void {
this._callback = callback;
this._onSubscribe();
this._context.stateManager.addListener(this._stateChangehandler);
}
public destroy(): void {
this._context.stateManager.removeListener(this._stateChangehandler);
this._forTimers.forEach((timer) => timer.stop());
this._forTimers.clear();
this._onDestroy();
this._callback = null;
}
protected abstract _processEntityChange(
entityID: string,
oldStateObj: HassEntity | undefined,
newStateObj: HassEntity | undefined,
): void;
protected _onSubscribe(): void {}
protected _onDestroy(): void {}
protected _entityIDs(): string[] {
return arrayify(this._trigger.entity_id ?? this._trigger.entity);
}
// Cancel a pending `for:` hold for an entity that left its matching condition.
protected _cancelForTimer(entityID: string): void {
this._forTimers.get(entityID)?.stop();
}
// Trigger immediately, or arm the `for:` hold so it triggers only after the
// condition has held for the configured duration.
protected _callTriggerOrHold(
entityID: string,
oldStateObj?: HassEntity,
newStateObj?: HassEntity,
): void {
if (!this._trigger.for) {
this._callTrigger(entityID, oldStateObj, newStateObj);
return;
}
const seconds = renderTimePeriodToSeconds(
this._context.templateRenderer,
this._trigger.for,
this._context.stateManager.getState(),
);
if (seconds !== null) {
this._getForTimer(entityID).start(seconds, () =>
this._callTrigger(entityID, oldStateObj, newStateObj),
);
}
}
private _stateChangehandler = (change: ConditionStateChange): void => {
for (const entityID of this._entityIDs()) {
const oldStateObj = change.old.hass?.states?.[entityID];
const newStateObj = change.new.hass?.states?.[entityID];
// Only entities whose state object actually changed are candidates.
if (isEqual(oldStateObj, newStateObj)) {
continue;
}
this._processEntityChange(entityID, oldStateObj, newStateObj);
}
};
private _getForTimer(entityID: string): Timer {
let timer = this._forTimers.get(entityID);
if (!timer) {
timer = new Timer();
this._forTimers.set(entityID, timer);
}
return timer;
}
private _callTrigger(
entityID: string,
oldStateObj?: HassEntity,
newStateObj?: HassEntity,
): void {
this._callback?.({
platform: this._platform,
entity_id: entityID,
entity: entityID,
...(oldStateObj && { from_state: oldStateObj }),
...(newStateObj && { to_state: newStateObj }),
});
}
}
@@ -0,0 +1,9 @@
import { ConditionState } from '../../conditions/types';
import { ConditionStateTriggerBase } from './condition-state-base';
import { TriggerOfType } from './types';
export class ExpandTrigger extends ConditionStateTriggerBase<TriggerOfType<'expand'>> {
protected _getValue(state: ConditionState): unknown {
return state.expand;
}
}
@@ -0,0 +1,11 @@
import { ConditionState } from '../../conditions/types';
import { ConditionStateTriggerBase } from './condition-state-base';
import { TriggerOfType } from './types';
export class FullscreenTrigger extends ConditionStateTriggerBase<
TriggerOfType<'fullscreen'>
> {
protected _getValue(state: ConditionState): unknown {
return state.fullscreen;
}
}
@@ -0,0 +1,11 @@
import { ConditionState } from '../../conditions/types';
import { ConditionStateTriggerBase } from './condition-state-base';
import { TriggerOfType } from './types';
export class InitializedTrigger extends ConditionStateTriggerBase<
TriggerOfType<'initialized'>
> {
protected _getValue(state: ConditionState): unknown {
return state.initialized;
}
}
@@ -0,0 +1,11 @@
import { ConditionState } from '../../conditions/types';
import { ConditionStateTriggerBase } from './condition-state-base';
import { TriggerOfType } from './types';
export class InteractionTrigger extends ConditionStateTriggerBase<
TriggerOfType<'interaction'>
> {
protected _getValue(state: ConditionState): unknown {
return state.interaction;
}
}
@@ -0,0 +1,15 @@
// Whether a rendered template result counts as true for a TRIGGER. Mirrors HA's
// `result_as_boolean`: a non-zero number, or `1`/`true`/`yes`/`on`/`enable`
// (case-insensitive), is truthy -- more permissive than a condition.
export const isTemplateTrue = (value: unknown): boolean => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
return ['1', 'true', 'yes', 'on', 'enable'].includes(value.toLowerCase());
}
return false;
};
@@ -0,0 +1,9 @@
import { ConditionState } from '../../conditions/types';
import { ConditionStateTriggerBase } from './condition-state-base';
import { TriggerOfType } from './types';
export class KeyTrigger extends ConditionStateTriggerBase<TriggerOfType<'key'>> {
protected _getValue(state: ConditionState): unknown {
return state.keys;
}
}
@@ -0,0 +1,11 @@
import { ConditionState } from '../../conditions/types';
import { ConditionStateTriggerBase } from './condition-state-base';
import { TriggerOfType } from './types';
export class MediaLoadedTrigger extends ConditionStateTriggerBase<
TriggerOfType<'media_loaded'>
> {
protected _getValue(state: ConditionState): unknown {
return state.mediaLoadedInfo != null;
}
}
@@ -0,0 +1,13 @@
import { ConditionState } from '../../conditions/types';
import { ConditionStateTriggerBase } from './condition-state-base';
import { TriggerOfType } from './types';
// Triggers when the microphone mute state changes: to the given value if `muted`
// is set, or on any change if it is omitted.
export class MicrophoneTrigger extends ConditionStateTriggerBase<
TriggerOfType<'microphone'>
> {
protected _getValue(state: ConditionState): unknown {
return state.microphone?.muted;
}
}
@@ -0,0 +1,79 @@
import { HassEntity } from 'home-assistant-js-websocket';
import { matchesNumericState, readNumericStateValue } from '../../common/numeric-state';
import { ConditionState } from '../../conditions/types';
import { EntityStateTriggerBase } from './entity-state-base';
import { TriggerOfType } from './types';
// https://www.home-assistant.io/docs/automation/trigger/#numeric-state-trigger
// Faithful to HA's numeric_state trigger
// (homeassistant/components/homeassistant/triggers/numeric_state.py): triggers
// on the *crossing* into the `above`/`below` range, not while merely in it.
// Each entity is "armed" while outside the range and triggers once on the
// transition in, then disarms until it leaves and re-arms. `for:` holds the
// trigger only while the value stays in range. The in-range check is shared
// with the numeric_state condition (`matchesNumericState`).
export class NumericStateTrigger extends EntityStateTriggerBase<
TriggerOfType<'numeric_state'>
> {
protected readonly _platform = 'numeric_state';
// Entities currently outside the range, armed to trigger on the next crossing in.
private _armedEntities = new Set<string>();
protected _onSubscribe(): void {
// Arm entities that start with a readable value outside the range, matching
// HA: an unreadable entity is not armed, so it does not trigger on its first
// valid in-range reading (only once it has been outside and crossed in).
const state = this._context.stateManager.getState();
for (const entityID of this._entityIDs()) {
if (
readNumericStateValue(
entityID,
state,
this._trigger,
this._context.templateRenderer,
) !== null &&
!this._matches(entityID, state)
) {
this._armedEntities.add(entityID);
}
}
}
protected _onDestroy(): void {
this._armedEntities.clear();
}
private _matches(entityID: string, state: ConditionState): boolean {
return matchesNumericState(
entityID,
state,
this._trigger,
this._context.templateRenderer,
);
}
protected _processEntityChange(
entityID: string,
oldStateObj: HassEntity | undefined,
newStateObj: HassEntity | undefined,
): void {
// During a state-change dispatch the manager's stored state is already the
// new state, so it is what this entity just changed to.
if (!this._matches(entityID, this._context.stateManager.getState())) {
// Outside the range: (re-)arm, and cancel any pending `for:` hold.
this._armedEntities.add(entityID);
this._cancelForTimer(entityID);
return;
}
// Inside the range but not armed: already in range, so not a crossing.
if (!this._armedEntities.has(entityID)) {
return;
}
// Crossing into the range: disarm and trigger (or start the `for:` hold).
this._armedEntities.delete(entityID);
this._callTriggerOrHold(entityID, oldStateObj, newStateObj);
}
}
@@ -0,0 +1,48 @@
import {
MediaQueryWatcher,
MediaQueryWatcherUnsubscribeCallback,
} from '../../common/media-query-watcher';
import { buildCardTriggerData } from '../build-trigger-data';
import { TriggerCallback, TriggerEvaluator, TriggerOfType } from './types';
// `screen` watches a matchMedia query, whose state lives outside the card's
// `ConditionState`, so it owns a `MediaQueryWatcher` (the same watcher the
// screen condition uses). It fires on the rising edge of the query match --
// consistent with "value present => fire on change to that value".
export class ScreenTrigger implements TriggerEvaluator {
private _trigger: TriggerOfType<'screen'>;
private _callback: TriggerCallback | null = null;
private _watcher: MediaQueryWatcher | null = null;
private _unsubscribeCallback: MediaQueryWatcherUnsubscribeCallback | null = null;
private _matched = false;
constructor(trigger: TriggerOfType<'screen'>) {
this._trigger = trigger;
}
public subscribe(callback: TriggerCallback): void {
if (!this._trigger.media_query) {
return;
}
this._callback = callback;
this._watcher = new MediaQueryWatcher(this._trigger.media_query);
this._matched = this._watcher.matches();
this._unsubscribeCallback = this._watcher.subscribe(this._handler);
}
public destroy(): void {
this._unsubscribeCallback?.();
this._unsubscribeCallback = null;
this._watcher = null;
this._callback = null;
}
private _handler = (): void => {
const matched = !!this._watcher?.matches();
if (matched && !this._matched) {
this._callback?.(buildCardTriggerData(this._trigger.trigger));
}
this._matched = matched;
};
}
@@ -0,0 +1,97 @@
import { HassEntity } from 'home-assistant-js-websocket';
import { arrayify } from '../../../utils/basic';
import { EntityStateTriggerBase } from './entity-state-base';
import { TriggerOfType } from './types';
// https://www.home-assistant.io/docs/automation/trigger/#state-trigger Faithful
// to HA's state trigger
// (homeassistant/components/homeassistant/triggers/state.py):
// per-`entity`/`entity_id`, `from`/`to`/`not_from`/`not_to` matchers (absent or
// `null` matches anything), the `match_all` attribute-triggering rule,
// `attribute`, and a per-entity `for:`.
export class StateTrigger extends EntityStateTriggerBase<TriggerOfType<'state'>> {
protected readonly _platform = 'state';
// True when any of `from`/`to`/`not_from`/`not_to` is set. A `null` config value
// (e.g. `to: null`) still counts as set: it matches any state value (see
// `_matches`), but its mere presence is what restricts triggering to *real
// state changes*. This is the whole significance of `to: null` vs omitting
// `to`: both match any value, but `to: null` will NOT trigger on attribute-only
// changes (a constraint exists), whereas omitting all four triggers on those
// too.
private _hasStateConstraint(): boolean {
const trigger = this._trigger;
return (
trigger.from !== undefined ||
trigger.not_from !== undefined ||
trigger.to !== undefined ||
trigger.not_to !== undefined
);
}
private _readValue(stateObj?: HassEntity): string | null {
if (!stateObj) {
return null;
}
const attribute = this._trigger.attribute;
if (attribute !== undefined) {
const value = stateObj.attributes?.[attribute];
return value === undefined || value === null ? null : String(value);
}
return stateObj.state;
}
// A value matches when it is in the positive set (`from`/`to`), or not in the
// negative set (`not_from`/`not_to`); an absent or `null` constraint matches
// anything.
private _matches(
value: string | null,
positive?: string | string[] | null,
negative?: string | string[] | null,
): boolean {
if (positive !== undefined && positive !== null) {
return value !== null && arrayify(positive).includes(value);
}
if (negative !== undefined && negative !== null) {
// An absent value (entity missing) is not in the set, so it matches.
return !(value !== null && arrayify(negative).includes(value));
}
return true;
}
protected _processEntityChange(
entityID: string,
oldStateObj: HassEntity | undefined,
newStateObj: HassEntity | undefined,
): void {
const trigger = this._trigger;
const oldValue = this._readValue(oldStateObj);
const newValue = this._readValue(newStateObj);
// When watching an attribute, ignore changes that don't move it.
if (trigger.attribute !== undefined && oldValue === newValue) {
return;
}
const hasStateConstraint = this._hasStateConstraint();
const matches =
this._matches(oldValue, trigger.from, trigger.not_from) &&
this._matches(newValue, trigger.to, trigger.not_to) &&
// from/to test the values but not that they *differ*, so an attribute-only
// event (value unchanged) can still satisfy them. Require a genuine change
// when a constraint is set; with none, trigger on those too.
!(hasStateConstraint && oldValue === newValue);
if (!matches) {
// Only a real change of the watched value cancels a pending `for:` hold;
// an attribute-only change (value unchanged) must leave it running, just
// as HA's `for:` keys off the state, not the whole state object.
if (oldValue !== newValue) {
this._cancelForTimer(entityID);
}
return;
}
this._callTriggerOrHold(entityID, oldStateObj, newStateObj);
}
}
@@ -0,0 +1,85 @@
import { Timer } from '../../../utils/timer';
import { renderTimePeriodToSeconds } from '../../common/time-period';
import { ConditionState, ConditionStateChange } from '../../conditions/types';
import { isTemplateTrue } from './is-template-true';
import {
TriggerCallback,
TriggerEvaluator,
TriggerEvaluatorContext,
TriggerOfType,
} from './types';
// https://www.home-assistant.io/docs/automation/trigger/#template-trigger
// Triggers when `value_template` renders true having previously been non-true
// (the rising edge); `for:` requires it to stay true for the duration first.
export class TemplateTrigger implements TriggerEvaluator {
private _trigger: TriggerOfType<'template'>;
private _context: TriggerEvaluatorContext;
private _callback: TriggerCallback | null = null;
private _forTimer = new Timer();
private _lastResult = false;
constructor(trigger: TriggerOfType<'template'>, context: TriggerEvaluatorContext) {
this._trigger = trigger;
this._context = context;
}
public subscribe(callback: TriggerCallback): void {
this._callback = callback;
// Establish the baseline without triggering: a template already true at
// subscribe must not trigger (HA triggers only on a transition to true).
this._lastResult = this._render(this._context.stateManager.getState());
this._context.stateManager.addListener(this._handler);
}
public destroy(): void {
this._context.stateManager.removeListener(this._handler);
this._forTimer.stop();
this._callback = null;
}
private _handler = (change: ConditionStateChange): void => {
const result = this._render(change.new);
const rising = result && !this._lastResult;
this._lastResult = result;
if (rising) {
const forPeriod = this._trigger.for;
if (!forPeriod) {
this._callTrigger();
return;
}
const seconds = renderTimePeriodToSeconds(
this._context.templateRenderer,
forPeriod,
change.new,
);
if (seconds !== null) {
this._forTimer.start(seconds, () => this._callTrigger());
}
} else if (!result) {
// No longer holds -- cancel any pending `for:` trigger.
this._forTimer.stop();
}
};
private _render(state: ConditionState): boolean {
return (
!!state.hass &&
isTemplateTrue(
this._context.templateRenderer.renderRecursively(
state.hass,
this._trigger.value_template,
{ conditionState: state },
),
)
);
}
private _callTrigger(): void {
this._callback?.({ platform: 'template' });
}
}
@@ -0,0 +1,11 @@
import { ConditionState } from '../../conditions/types';
import { ConditionStateTriggerBase } from './condition-state-base';
import { TriggerOfType } from './types';
export class TriggeredTrigger extends ConditionStateTriggerBase<
TriggerOfType<'triggered'>
> {
protected _getValue(state: ConditionState): unknown {
return state.triggered;
}
}
@@ -0,0 +1,23 @@
import { TemplateRenderer } from '../../../card-controller/templates';
import { Trigger } from '../../../config/schema/condition-trigger/triggers/types';
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
import { TriggerData } from '../types';
export type TriggerCallback = (data: TriggerData) => void;
export interface TriggerEvaluatorContext {
stateManager: ConditionStateManagerReadonlyInterface;
templateRenderer: TemplateRenderer;
}
export type TriggerOfType<T extends string> = Extract<Trigger, { trigger: T }>;
/**
* A single trigger built from one `triggers:` entry: it watches its source and
* invokes `callback` when its event occurs. The push-based sibling of the
* pull-based `ConditionEvaluator`.
*/
export interface TriggerEvaluator {
subscribe(callback: TriggerCallback): void;
destroy(): void;
}
@@ -0,0 +1,11 @@
import { ConditionState } from '../../conditions/types';
import { ConditionStateTriggerBase } from './condition-state-base';
import { TriggerOfType } from './types';
// Triggers when the selected view changes: to one of `views` if listed, or on
// any change if `views` is omitted.
export class ViewTrigger extends ConditionStateTriggerBase<TriggerOfType<'view'>> {
protected _getValue(state: ConditionState): unknown {
return state.view;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { HassEntity } from 'home-assistant-js-websocket';
import { TemplateAdvancedCameraCardState } from '../../card-controller/templates/types';
// The top-level `trigger` template variable produced each time an evaluator
// triggers. `platform` is the provider -- a real HA platform for stock
// triggers, or `acc` for the card's own triggers (whose specific kind is then
// in `type`, mirroring HA's device-trigger platform/type split).
export interface TriggerData {
platform: string;
type?: string;
// Stock (HA-faithful) fields:
entity_id?: string;
entity?: string;
from_state?: HassEntity;
to_state?: HassEntity;
// Card (`acc` platform) fields -- full before/after card-state trigger data:
from_acc?: TemplateAdvancedCameraCardState;
to_acc?: TemplateAdvancedCameraCardState;
}