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
+44 -13
View File
@@ -1,5 +1,6 @@
import { ActionContext } from 'action';
import { z } from 'zod';
import { TriggerData } from '../../condition-trigger/triggers/types.js';
import {
ActionConfig,
Actions,
@@ -14,7 +15,11 @@ import { allPromises, errorToConsole } from '../../utils/basic.js';
import { TemplateRenderer } from '../templates/index.js';
import { CardActionsManagerAPI } from '../types.js';
import { ActionSet } from './actions/set.js';
import { ActionsExecutionRequest, ActionsExecutor } from './types.js';
import {
ActionPrepareCallback,
ActionsExecutionRequest,
ActionsExecutor,
} from './types.js';
const INTERACTIONS = ['tap', 'double_tap', 'hold', 'start_tap', 'end_tap'] as const;
export type InteractionName = (typeof INTERACTIONS)[number];
@@ -138,23 +143,31 @@ export class ActionsManager implements ActionsExecutor {
request: ActionsExecutionRequest,
renderTemplates = true,
): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
const renderedAction: ActionConfig | ActionConfig[] =
renderTemplates && hass && this._templateRenderer
? (this._templateRenderer.renderRecursively(hass, request.actions, {
conditionState: this._api.getConditionStateManager().getState(),
triggerData: request?.triggerData,
}) as ActionConfig | ActionConfig[])
: request.actions;
const allowedActions = this._api.getLockManager().getAllowedActions(renderedAction);
// Lock filtering and the factory both classify on the raw (unrendered)
// discriminator (`action` / `advanced_camera_card_action`). A templated
// `advanced_camera_card_action` (permitted only by the loose custom-action
// schema) is left unresolved, matches no action type, and is dropped.
const allowedActions = this._api.getLockManager().getAllowedActions(request.actions);
if (!allowedActions.length) {
return;
}
// Each action prepares itself (via Action.prepare) just before it runs, so
// it observes what an earlier action may have changed. Skip when the caller
// opts out (the templates are already rendered) or there is no renderer.
const renderer = this._templateRenderer;
const actionPrepareCallback =
renderTemplates && renderer
? this._createActionPrepareCallback(renderer, request.triggerData)
: undefined;
const actionSet = new ActionSet(this._actionContext, allowedActions, {
config: request.config,
cardID: this._api.getConfigManager().getConfig()?.card_id,
factoryOptions: {
config: request.config,
cardID: this._api.getConfigManager().getConfig()?.card_id,
triggerData: request?.triggerData,
},
actionPrepareCallback,
});
this._actionsInFlight.push(actionSet);
@@ -168,4 +181,22 @@ export class ActionsManager implements ActionsExecutor {
}
this._actionsInFlight = this._actionsInFlight.filter((a) => a !== actionSet);
}
private _createActionPrepareCallback(
renderer: TemplateRenderer,
triggerData?: TriggerData,
): ActionPrepareCallback {
// Render against the state (incl. HASS) as it is *when the action runs* --
// fixed trigger context, fresh card/HASS state per step. The one cast lives
// here as renderRecursively returns `unknown`.
return <T>(value: T): T => {
const hass = this._api.getHASSManager().getHASS();
return hass
? (renderer.renderRecursively(hass, value, {
conditionState: this._api.getConditionStateManager().getState(),
triggerData,
}) as T)
: value;
};
}
}
+18 -11
View File
@@ -4,24 +4,35 @@ import {
AuxillaryActionConfig,
} from '../../../config/schema/actions/types.js';
import { localize } from '../../../localize/localize.js';
import { isAdvancedCameraCardCustomAction } from '../../../utils/action';
import { getActionName } from '../../../utils/action';
import { CardActionsAPI } from '../../types';
import { Action, ActionAbortError } from '../types';
import { Action, ActionAbortError, ActionPrepareCallback } from '../types';
export class BaseAction<T extends ActionConfig> implements Action {
protected _context: ActionContext;
protected _action: T;
protected _rawAction: T;
protected _preparedAction: T | null = null;
protected _config?: AuxillaryActionConfig;
constructor(context: ActionContext, action: T, config?: AuxillaryActionConfig) {
this._context = context;
this._action = action;
this._rawAction = action;
this._config = config;
}
public prepare(actionPrepareCallback: ActionPrepareCallback): void {
this._preparedAction = actionPrepareCallback(this._rawAction);
}
// The config to act on: the prepared (rendered) form once prepare() has run,
// otherwise the raw config (e.g. under direct execution without a prepare).
protected _getAction(): T {
return this._preparedAction ?? this._rawAction;
}
protected _shouldSeekConfirmation(api: CardActionsAPI): boolean {
const hass = api.getHASSManager().getHASS();
const action: ActionConfig = this._action;
const action: ActionConfig = this._getAction();
return (
(typeof action.confirmation === 'boolean' && action.confirmation) ||
@@ -33,14 +44,10 @@ export class BaseAction<T extends ActionConfig> implements Action {
public async execute(api: CardActionsAPI): Promise<void> {
if (this._shouldSeekConfirmation(api)) {
const action: ActionConfig = this._action;
const baseAction = action.action;
const actionName = isAdvancedCameraCardCustomAction(action)
? action.advanced_camera_card_action
: baseAction;
const action: ActionConfig = this._getAction();
const text =
(typeof action.confirmation === 'object' ? action.confirmation.text : null) ??
`${localize('actions.confirmation')}: ${actionName}`;
`${localize('actions.confirmation')}: ${getActionName(action)}`;
if (!confirm(text)) {
throw new ActionAbortError(localize('actions.abort'));
}
@@ -11,7 +11,8 @@ export class CallServiceAction extends AdvancedCameraCardAction<CallServiceActio
return;
}
const [domain, service] = this._action.service.split('.', 2);
await hass.callService(domain, service, this._action.data, this._action.target);
const action = this._getAction();
const [domain, service] = action.service.split('.', 2);
await hass.callService(domain, service, action.data, action.target);
}
}
@@ -6,9 +6,10 @@ export class CallStartAction extends AdvancedCameraCardAction<CallStartActionCon
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const action = this._getAction();
await api.getCallManager().start({
cameraID: this._action.camera,
streamID: this._action.stream,
cameraID: action.camera,
streamID: action.stream,
});
}
}
@@ -6,10 +6,11 @@ export class CameraSelectAction extends AdvancedCameraCardAction<CameraSelectAct
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const action = this._getAction();
const selectCameraID =
this._action.camera ??
(this._action.triggered
? api.getTriggersManager().getMostRecentlyTriggeredCameraID()
action.camera ??
(action.triggered
? api.getCameraTriggersManager().getMostRecentlyTriggeredCameraID()
: null);
const view = api.getViewManager().getView();
const config = api.getConfigManager().getConfig();
@@ -7,6 +7,10 @@ export class CustomAction extends AdvancedCameraCardAction<CustomActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
fireHASSEvent(api.getCardElementManager().getElement(), 'll-custom', this._action);
fireHASSEvent(
api.getCardElementManager().getElement(),
'll-custom',
this._getAction(),
);
}
}
@@ -8,7 +8,7 @@ export class DisplayModeSelectAction extends AdvancedCameraCardAction<DisplayMod
await api.getViewManager().setViewByParametersWithNewQuery({
params: {
displayMode: this._action.display_mode,
displayMode: this._getAction().display_mode,
},
});
}
@@ -6,15 +6,16 @@ export class EffectAction extends AdvancedCameraCardAction<EffectActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
switch (this._action.effect_action) {
const action = this._getAction();
switch (action.effect_action) {
case 'start':
api.getEffectsManager().startEffect(this._action.effect);
api.getEffectsManager().startEffect(action.effect);
break;
case 'stop':
api.getEffectsManager().stopEffect(this._action.effect);
api.getEffectsManager().stopEffect(action.effect);
break;
case 'toggle':
api.getEffectsManager().toggleEffect(this._action.effect);
api.getEffectsManager().toggleEffect(action.effect);
break;
}
}
+64
View File
@@ -0,0 +1,64 @@
import { ActionContext } from 'action';
import { createConditionEvaluator } from '../../../condition-trigger/conditions/factory';
import { TriggerData } from '../../../condition-trigger/triggers/types';
import {
AuxillaryActionConfig,
IfActionConfig,
} from '../../../config/schema/actions/types';
import { TemplateRenderer } from '../../templates/index';
import { CardActionsAPI } from '../../types';
import { ActionPrepareCallback } from '../types';
import { BaseAction } from './base';
export class IfAction extends BaseAction<IfActionConfig> {
private _triggerData?: TriggerData;
constructor(
context: ActionContext,
action: IfActionConfig,
config?: AuxillaryActionConfig,
triggerData?: TriggerData,
) {
super(context, action, config);
this._triggerData = triggerData;
}
public prepare(actionPrepareCallback: ActionPrepareCallback): void {
// Render this action's own fields (including the `if` conditions, so their
// `trigger.*` templates resolve), but leave `then`/`else` raw: they are
// nested action sequences that render per-step when their own branch runs.
const { then: thenBranch, else: elseBranch, ...rest } = this._rawAction;
this._preparedAction = {
...actionPrepareCallback(rest),
then: thenBranch,
...(elseBranch !== undefined && { else: elseBranch }),
};
}
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const action = this._getAction();
const evaluatorContext = { templateRenderer: new TemplateRenderer() };
const state = api.getConditionStateManager().getState();
const conditionsHold = action.if.every(
(condition) =>
createConditionEvaluator(condition, evaluatorContext).evaluate(state).result,
);
const branch = conditionsHold ? action.then : action.else;
if (!branch?.length) {
return;
}
// The branch renders per-step as it runs, so each action observes state an
// earlier branch action changed. The trigger data is forwarded so the
// branch can still resolve `trigger.*` templates.
await api.getActionsManager().executeActions({
actions: branch,
config: this._config,
triggerData: this._triggerData,
});
}
}
@@ -6,6 +6,6 @@ export class InternalCallbackAction extends AdvancedCameraCardAction<InternalCal
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await this._action.callback(api);
await this._getAction().callback(api);
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ export class LogAction extends AdvancedCameraCardAction<LogActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
console[this._action.level](this._action.message);
const action = this._getAction();
console[action.level](action.message);
}
}
@@ -8,10 +8,11 @@ export class MediaPlayerAction extends AdvancedCameraCardAction<MediaPlayerActio
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const mediaPlayer = this._action.media_player;
const action = this._getAction();
const mediaPlayer = action.media_player;
const mediaPlayerController = api.getMediaPlayerManager();
if (this._action.media_player_action === 'stop') {
if (action.media_player_action === 'stop') {
await mediaPlayerController.stop(mediaPlayer);
return;
}
@@ -7,7 +7,7 @@ export class MoreInfoAction extends AdvancedCameraCardAction<MoreInfoActionConfi
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const entityID = this._action.entity ?? this._config?.entity ?? null;
const entityID = this._getAction().entity ?? this._config?.entity ?? null;
if (!entityID) {
return;
}
@@ -7,13 +7,14 @@ export class NavigateAction extends AdvancedCameraCardAction<NavigateActionConfi
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
if (!!this._action.navigation_replace) {
history.replaceState(null, '', this._action.navigation_path);
const action = this._getAction();
if (!!action.navigation_replace) {
history.replaceState(null, '', action.navigation_path);
} else {
history.pushState(null, '', this._action.navigation_path);
history.pushState(null, '', action.navigation_path);
}
fireHASSEvent(window, 'location-changed', {
replace: !!this._action.navigation_replace,
replace: !!action.navigation_replace,
});
}
}
@@ -5,6 +5,6 @@ import { AdvancedCameraCardAction } from './base';
export class NotificationAction extends AdvancedCameraCardAction<NotificationActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getNotificationManager().setNotification(this._action.notification);
api.getNotificationManager().setNotification(this._getAction().notification);
}
}
@@ -11,7 +11,8 @@ export class PerformActionAction extends AdvancedCameraCardAction<PerformActionA
return;
}
const [domain, service] = this._action.perform_action.split('.', 2);
await hass.callService(domain, service, this._action.data, this._action.target);
const action = this._getAction();
const [domain, service] = action.perform_action.split('.', 2);
await hass.callService(domain, service, action.data, action.target);
}
}
@@ -6,6 +6,7 @@ export class PTZControlsAction extends AdvancedCameraCardAction<PTZControlsActio
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const action = this._getAction();
const currentEnabled = api.getViewManager().getView()?.context?.ptzControls?.enabled;
// If `enabled` is explicit, use it. If only `type` is being changed, leave
@@ -13,8 +14,8 @@ export class PTZControlsAction extends AdvancedCameraCardAction<PTZControlsActio
// toggle the current enabled value — this is the menu-button show/hide use
// case.
const enabled =
this._action.enabled ??
(this._action.type
action.enabled ??
(action.type
? undefined
: currentEnabled === undefined
? undefined
@@ -23,7 +24,7 @@ export class PTZControlsAction extends AdvancedCameraCardAction<PTZControlsActio
api.getViewManager().setViewWithMergedContext({
ptzControls: {
...(enabled !== undefined && { enabled }),
...(this._action.type && { type: this._action.type }),
...(action.type && { type: action.type }),
},
});
}
@@ -48,25 +48,26 @@ export class PTZDigitalAction extends AdvancedCameraCardAction<PTZDigitialAction
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const action = this._getAction();
const view = api.getViewManager().getView();
if (!view) {
return;
}
const targetID =
this._action.target_id ??
action.target_id ??
getPTZTarget(view, { type: 'digital', cameraManager: api.getCameraManager() })
?.targetID;
if (!targetID) {
return;
}
if (!!this._action.absolute || !this._action.ptz_phase) {
if (!!action.absolute || !action.ptz_phase) {
return await this._stepChange(api, targetID);
}
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (this._action.ptz_phase === 'start') {
if (action.ptz_phase === 'start') {
await stopInProgressForThisTarget(targetID, this._context.ptzDigital);
setInProgressForThisTarget(targetID, this._context, 'ptzDigital', this);
@@ -74,21 +75,22 @@ export class PTZDigitalAction extends AdvancedCameraCardAction<PTZDigitialAction
this._timer.startRepeated(STEP_DELAY_SECONDS, () =>
this._stepChange(api, targetID),
);
} else if (this._action.ptz_phase === 'stop') {
} else if (action.ptz_phase === 'stop') {
await stopInProgressForThisTarget(targetID, this._context.ptzDigital);
delete this._context.ptzDigital?.[targetID];
}
}
private _convertActionToZoomSettings(base?: PartialZoomSettings): PartialZoomSettings {
if (!this._action.absolute && !this._action.ptz_action) {
const action = this._getAction();
if (!action.absolute && !action.ptz_action) {
// If neither an absolute position nor an action are specified, the request
// is assumed to be to return to default.
return {};
}
if (this._action.absolute) {
return this._action.absolute;
if (action.absolute) {
return action.absolute;
}
const zoom = base?.zoom ?? ZOOM_DEFAULT_SCALE;
@@ -98,21 +100,21 @@ export class PTZDigitalAction extends AdvancedCameraCardAction<PTZDigitialAction
};
const zoomDelta =
this._action.ptz_action === 'zoom_in'
action.ptz_action === 'zoom_in'
? STEP_ZOOM
: this._action.ptz_action === 'zoom_out'
: action.ptz_action === 'zoom_out'
? -STEP_ZOOM
: 0;
const xDelta =
this._action.ptz_action === 'left'
action.ptz_action === 'left'
? -STEP_PAN
: this._action.ptz_action === 'right'
: action.ptz_action === 'right'
? STEP_PAN
: 0;
const yDelta =
this._action.ptz_action === 'up'
action.ptz_action === 'up'
? -STEP_PAN
: this._action.ptz_action === 'down'
: action.ptz_action === 'down'
? STEP_PAN
: 0;
@@ -14,8 +14,9 @@ export class PTZMultiAction extends AdvancedCameraCardAction<PTZMultiActionConfi
let targetID: string | null = null;
let type: PTZType | null = null;
if (this._action.target_id) {
targetID = this._action.target_id;
const action = this._getAction();
if (action.target_id) {
targetID = action.target_id;
type = hasCameraTruePTZ(api.getCameraManager(), targetID) ? 'ptz' : 'digital';
} else if (view) {
const multiTarget = getPTZTarget(view, { cameraManager: api.getCameraManager() });
@@ -34,26 +35,28 @@ export class PTZMultiAction extends AdvancedCameraCardAction<PTZMultiActionConfi
}
private _toPTZAction(targetID: string): PTZAction {
const action = this._getAction();
return new PTZAction(
this._context,
createPTZAction({
cardID: this._action.card_id,
cardID: action.card_id,
cameraID: targetID,
ptzAction: this._action.ptz_action,
ptzPhase: this._action.ptz_phase,
ptzPreset: this._action.ptz_preset,
ptzAction: action.ptz_action,
ptzPhase: action.ptz_phase,
ptzPreset: action.ptz_preset,
}),
this._config,
);
}
private _toPTZDigitalAction(targetID: string): PTZDigitalAction {
const action = this._getAction();
return new PTZDigitalAction(
this._context,
createPTZDigitalAction({
cardID: this._action.card_id,
ptzPhase: this._action.ptz_phase,
ptzAction: this._action.ptz_action,
cardID: action.card_id,
ptzPhase: action.ptz_phase,
ptzAction: action.ptz_action,
targetID: targetID,
}),
this._config,
+26 -30
View File
@@ -33,13 +33,15 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const action = this._getAction();
const view = api.getViewManager().getView();
if (!view) {
return;
}
const ptzCameraID =
this._action.camera ??
action.camera ??
getPTZTarget(view, { type: 'ptz', cameraManager: api.getCameraManager() })
?.targetID ??
null;
@@ -53,34 +55,34 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
return;
}
if (!this._action.ptz_action) {
if (!action.ptz_action) {
if (ptzCapabilities.presets && ptzCapabilities.presets.length >= 1) {
await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', {
phase: this._action.ptz_phase,
phase: action.ptz_phase,
preset: ptzCapabilities.presets[0],
});
}
return;
}
const capabilityKey = ptzActionToCapabilityKey(this._action.ptz_action);
const capabilityKey = ptzActionToCapabilityKey(action.ptz_action);
if (
(capabilityKey &&
ptzCapabilities[capabilityKey]?.includes(
this._action.ptz_phase ? PTZMovementType.Continuous : PTZMovementType.Relative,
action.ptz_phase ? PTZMovementType.Continuous : PTZMovementType.Relative,
)) ||
this._action.ptz_action === 'preset'
action.ptz_action === 'preset'
) {
// Scenario: Camera natively supports requested move type.
return await api
.getCameraManager()
.executePTZAction(ptzCameraID, this._action.ptz_action, {
phase: this._action.ptz_phase,
preset: this._action.ptz_preset,
.executePTZAction(ptzCameraID, action.ptz_action, {
phase: action.ptz_phase,
preset: action.ptz_preset,
});
}
if (this._action.ptz_phase === 'start') {
if (action.ptz_phase === 'start') {
// Scenario: Asked to start a continuous move, camera only supports relative moves natively.
await stopInProgressForThisTarget(ptzCameraID, this._context.ptz);
setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this);
@@ -88,12 +90,10 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
const singleStep = async (): Promise<void> => {
/* istanbul ignore else: the else path cannot be reached as ptz_action
being present is checked above -- @preserve */
if (this._action.ptz_action) {
await api
.getCameraManager()
.executePTZAction(ptzCameraID, this._action.ptz_action, {
preset: this._action.ptz_preset,
});
if (action.ptz_action) {
await api.getCameraManager().executePTZAction(ptzCameraID, action.ptz_action, {
preset: action.ptz_preset,
});
}
if (!this._stopped) {
@@ -109,30 +109,26 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
this._stopped = false;
await singleStep();
} else if (this._action.ptz_phase === 'stop') {
} else if (action.ptz_phase === 'stop') {
// Scenario: Asked to stop continuous move, camera only supports relative moves natively.
await stopInProgressForThisTarget(ptzCameraID, this._context.ptz);
} else {
this._stopped = false;
// Relative move (but camera only supports continuous).
await api
.getCameraManager()
.executePTZAction(ptzCameraID, this._action.ptz_action, {
preset: this._action.ptz_preset,
phase: 'start',
});
await api.getCameraManager().executePTZAction(ptzCameraID, action.ptz_action, {
preset: action.ptz_preset,
phase: 'start',
});
this._timer.start(ptzConfiguration.c2r_delay_between_calls_seconds, async () => {
/* istanbul ignore else: the else path cannot be reached as ptz_action
being present is checked above -- @preserve */
if (this._action.ptz_action) {
await api
.getCameraManager()
.executePTZAction(ptzCameraID, this._action.ptz_action, {
preset: this._action.ptz_preset,
phase: 'stop',
});
if (action.ptz_action) {
await api.getCameraManager().executePTZAction(ptzCameraID, action.ptz_action, {
preset: action.ptz_preset,
phase: 'stop',
});
}
});
}
@@ -18,7 +18,7 @@ export class SetReviewAction extends AdvancedCameraCardAction<SetReviewActionCon
return;
}
const targetReviewedState = this._action.reviewed;
const targetReviewedState = this._getAction().reviewed;
if (targetReviewedState !== undefined && targetReviewedState === item.isReviewed()) {
return;
}
+30 -19
View File
@@ -1,34 +1,32 @@
import { ActionContext } from 'action';
import {
ActionConfig,
AuxillaryActionConfig,
} from '../../../config/schema/actions/types';
import { ActionConfig } from '../../../config/schema/actions/types';
import { arrayify } from '../../../utils/basic';
import { CardActionsAPI } from '../../types';
import { ActionFactory } from '../factory';
import { Action } from '../types';
import { ActionFactory, ActionFactoryOptions } from '../factory';
import { ActionPrepareCallback } from '../types';
export class ActionSet implements Action {
interface ActionSetOptions {
factoryOptions?: ActionFactoryOptions;
actionPrepareCallback?: ActionPrepareCallback;
}
export class ActionSet {
private _context: ActionContext;
private _actions: Action[] = [];
private _actions: ActionConfig[];
private _factoryOptions?: ActionFactoryOptions;
private _actionPrepareCallback?: ActionPrepareCallback;
private _factory = new ActionFactory();
private _stopped = false;
constructor(
context: ActionContext,
actions: ActionConfig | ActionConfig[],
options?: {
config?: AuxillaryActionConfig;
cardID?: string;
},
options?: ActionSetOptions,
) {
this._context = context;
for (const actionObj of arrayify(actions)) {
const action = this._factory.createAction(context, actionObj, options);
if (action) {
this._actions.push(action);
}
}
this._actions = arrayify(actions);
this._actionPrepareCallback = options?.actionPrepareCallback;
this._factoryOptions = options?.factoryOptions;
}
public async execute(api: CardActionsAPI): Promise<void> {
@@ -37,7 +35,20 @@ export class ActionSet implements Action {
break;
}
await action.execute(api);
const concreteAction = this._factory.createAction(
this._context,
action,
this._factoryOptions,
);
if (concreteAction) {
// Prepare against the state as it is now, so an action observes what an
// earlier action in the sequence changed. A prepare error aborts the
// rest of the sequence (it propagates to the caller's handler).
if (this._actionPrepareCallback) {
concreteAction.prepare(this._actionPrepareCallback);
}
await concreteAction.execute(api);
}
}
}
+1 -1
View File
@@ -8,6 +8,6 @@ export class SleepAction extends AdvancedCameraCardAction<SleepActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await sleep(timeDeltaToSeconds(this._action.duration));
await sleep(timeDeltaToSeconds(this._getAction().duration));
}
}
@@ -6,17 +6,18 @@ export class StatusBarAction extends AdvancedCameraCardAction<StatusBarActionCon
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
switch (this._action.status_bar_action) {
const action = this._getAction();
switch (action.status_bar_action) {
case 'reset':
api.getStatusBarItemManager().removeAllDynamicStatusBarItems();
break;
case 'add':
this._action.items?.forEach((item) =>
action.items?.forEach((item) =>
api.getStatusBarItemManager().addDynamicStatusBarItem(item),
);
break;
case 'remove':
this._action.items?.forEach((item) =>
action.items?.forEach((item) =>
api.getStatusBarItemManager().removeDynamicStatusBarItem(item),
);
break;
@@ -8,7 +8,7 @@ export class SubstreamOffAction extends AdvancedCameraCardAction<SubstreamOffAct
await super.execute(api);
api.getViewManager().setViewByParameters({
modifiers: [new SubstreamViewModifier({ camera: this._action.camera })],
modifiers: [new SubstreamViewModifier({ camera: this._getAction().camera })],
});
}
}
@@ -14,13 +14,14 @@ export class SubstreamOnAction extends AdvancedCameraCardAction<SubstreamOnActio
return;
}
const cameraID = this._action.camera ?? view.camera;
const action = this._getAction();
const cameraID = action.camera ?? view.camera;
if (!cameraID) {
return;
}
const stream =
this._action.stream ??
action.stream ??
this._getCycledSubstreamID(view, cameraID, api.getCameraManager());
api.getViewManager().setViewByParameters({
+1 -1
View File
@@ -6,6 +6,6 @@ export class URLAction extends AdvancedCameraCardAction<URLActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
window.open(this._action.url_path);
window.open(this._getAction().url_path);
}
}
+4 -3
View File
@@ -6,13 +6,14 @@ export class ViewAction extends AdvancedCameraCardAction<ViewActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const action = this._getAction();
await api.getViewManager().setViewByParametersWithNewQuery({
params: {
view: this._action.advanced_camera_card_action,
view: action.advanced_camera_card_action,
},
...(this._action.folder && {
...(action.folder && {
queryExecutorOptions: {
folder: this._action.folder,
folder: action.folder,
},
}),
});
+23 -7
View File
@@ -1,7 +1,8 @@
import { ActionContext } from 'action';
import { TriggerData } from '../../condition-trigger/triggers/types';
import { INTERNAL_CALLBACK_ACTION } from '../../config/schema/actions/custom/internal';
import { ActionConfig, AuxillaryActionConfig } from '../../config/schema/actions/types';
import { isAdvancedCameraCardCustomAction } from '../../utils/action';
import { isAdvancedCameraCardCustomAction, isIfAction } from '../../utils/action';
import { CallAnswerAction } from './actions/call-answer';
import { CallEndAction } from './actions/call-end';
import { CallServiceAction } from './actions/call-service';
@@ -15,6 +16,7 @@ import { DownloadAction } from './actions/download';
import { EffectAction } from './actions/effect';
import { ExpandAction } from './actions/expand';
import { FullscreenAction } from './actions/fullscreen';
import { IfAction } from './actions/if';
import { InfoAction } from './actions/info';
import { InternalCallbackAction } from './actions/internal-callback';
import { LogAction } from './actions/log';
@@ -50,23 +52,36 @@ import { URLAction } from './actions/url';
import { ViewAction } from './actions/view';
import { Action } from './types';
export interface ActionFactoryOptions {
config?: AuxillaryActionConfig;
cardID?: string;
// The firing automation's trigger payload (if any), forwarded to actions with
// nested actions (e.g. `if`) so their branches can still resolve `trigger.*`
// templates when they render per-step.
triggerData?: TriggerData;
}
export class ActionFactory {
public createAction(
context: ActionContext,
action: ActionConfig,
options?: {
config?: AuxillaryActionConfig;
cardID?: string;
},
options?: ActionFactoryOptions,
): Action | null {
if (
// Command not intended for this card (e.g. query string command).
// `card_id` is a static routing identifier, matched on the raw (template
// unrendered) config.
action.card_id &&
action.card_id !== options?.cardID
) {
return null;
}
if (isIfAction(action)) {
return new IfAction(context, action, options?.config, options?.triggerData);
}
switch (action.action) {
case 'more-info':
return new MoreInfoAction(context, action, options?.config);
@@ -182,11 +197,12 @@ export class ActionFactory {
return new InternalCallbackAction(context, action, options?.config);
}
/* istanbul ignore next: this path cannot be reached -- @preserve */
// Reached when the discriminator is not a known action type -- e.g. a
// templated `advanced_camera_card_action`, which is classified on the raw
// (unrendered) action and so never matches a case.
console.warn(
`Advanced Camera Card received unknown card action: ${action['advanced_camera_card_action']}`,
);
/* istanbul ignore next: this path cannot be reached -- @preserve */
return null;
}
}
+13 -2
View File
@@ -1,4 +1,4 @@
import { ConditionsTriggerData } from '../../conditions/types.js';
import { TriggerData } from '../../condition-trigger/triggers/types.js';
import {
ActionConfig,
AuxillaryActionConfig,
@@ -6,7 +6,18 @@ import {
import { AdvancedCameraCardError } from '../../types.js';
import { CardActionsAPI } from '../types';
// Renders a value's templates, returning a rendered copy. Generic so it
// preserves the value's type (the one cast lives in the renderer that supplies
// it).
export type ActionPrepareCallback = <T>(value: T) => T;
export interface Action {
// Prepare this action for execution by rendering its templates against the
// current state. The rendered copy is stored separately; the original config
// is left intact, so the action stays reusable. Structural actions (`if`)
// override this to leave their nested action sequences raw, so those render
// per-step when their branch runs.
prepare(actionPrepareCallback: ActionPrepareCallback): void;
execute(api: CardActionsAPI): Promise<void>;
stop(): Promise<void>;
}
@@ -14,7 +25,7 @@ export interface Action {
export interface ActionsExecutionRequest {
actions: ActionConfig[] | ActionConfig;
config?: AuxillaryActionConfig;
triggerData?: ConditionsTriggerData;
triggerData?: TriggerData;
}
export interface ActionsExecutor {
+46 -23
View File
@@ -1,7 +1,10 @@
import { ConditionsManager } from '../conditions/conditions-manager.js';
import { ConditionsEvaluationResult } from '../conditions/types.js';
import { ConditionEvaluator } from '../condition-trigger/conditions/conditions/types.js';
import { createConditionEvaluator } from '../condition-trigger/conditions/factory.js';
import { TriggersManager } from '../condition-trigger/triggers/manager.js';
import { TriggerData } from '../condition-trigger/triggers/types.js';
import { Automation, AutomationActions } from '../config/schema/automations.js';
import { localize } from '../localize/localize.js';
import { TemplateRenderer } from './templates/index.js';
import { CardAutomationsAPI, TaggedAutomation } from './types.js';
const MAX_NESTED_AUTOMATION_EXECUTIONS = 10;
@@ -9,7 +12,7 @@ const MAX_NESTED_AUTOMATION_EXECUTIONS = 10;
export class AutomationsManager {
private _api: CardAutomationsAPI;
private _automations = new Map<TaggedAutomation, ConditionsManager>();
private _automations = new Map<TaggedAutomation, TriggersManager>();
// A counter to avoid infinite loops, increases every time actions are run,
// decreases every time actions are complete.
@@ -20,28 +23,39 @@ export class AutomationsManager {
}
public deleteAutomations(tag?: unknown) {
for (const [automation, conditionManager] of this._automations) {
for (const [automation, triggers] of this._automations) {
if (automation.tag === tag) {
this._automations.delete(automation);
conditionManager.destroy();
triggers.destroy();
}
}
}
public addAutomations(automations: TaggedAutomation[]): void {
const context = { templateRenderer: new TemplateRenderer() };
for (const automation of automations) {
const conditionManager = new ConditionsManager(
automation.conditions,
const triggers = new TriggersManager(
automation.triggers,
this._api.getConditionStateManager(),
);
conditionManager.addListener((result: ConditionsEvaluationResult) =>
this._execute(automation, result),
// The ongoing `conditions:` block is pull-evaluated at trigger time, so
// its evaluators are never subscribed and hold no resources to tear down.
// They live in the trigger callback and are released when `triggers` is
// destroyed.
const conditions = (automation.conditions ?? []).map((condition) =>
createConditionEvaluator(condition, context),
);
this._automations.set(automation, conditionManager);
triggers.addListener((data) => this._execute(automation, conditions, data));
this._automations.set(automation, triggers);
}
}
private _execute(automation: Automation, result: ConditionsEvaluationResult): void {
private _execute(
automation: Automation,
conditions: ConditionEvaluator[],
triggerData: TriggerData,
): void {
if (
!this._api.getHASSManager().hasHASS() ||
// Never execute automations if the card hasn't finished initializing, as
@@ -55,17 +69,25 @@ export class AutomationsManager {
return;
}
const shouldExecute = result.result;
const actions = shouldExecute ? automation.actions : automation.actions_not;
// Evaluate the ongoing conditions against the current state at the instant
// the automation is triggered. The state manager updates its stored state
// before dispatching to listeners, so this already reflects the triggering
// change.
const state = this._api.getConditionStateManager().getState();
const ongoingConditionsHold = conditions.every(
(evaluator) => evaluator.evaluate(state).result,
);
if (!actions?.length) {
if (!ongoingConditionsHold || !automation.actions.length) {
return;
}
const runActions = async (actions: AutomationActions): Promise<void> => {
++this._nestedAutomationExecutions;
if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) {
// Check the limit *before* incrementing, so the overflow path holds no
// increment to leak; the `finally` then guarantees the decrement even if
// executing the actions throws. Either leak would permanently inflate the
// counter and eventually block all automations.
if (this._nestedAutomationExecutions >= MAX_NESTED_AUTOMATION_EXECUTIONS) {
this._api.getNotificationManager().setNotification({
heading: {
text: localize('error.too_many_automations'),
@@ -76,12 +98,13 @@ export class AutomationsManager {
return;
}
await this._api
.getActionsManager()
.executeActions({ actions, triggerData: result.triggerData });
--this._nestedAutomationExecutions;
++this._nestedAutomationExecutions;
try {
await this._api.getActionsManager().executeActions({ actions, triggerData });
} finally {
--this._nestedAutomationExecutions;
}
};
runActions(actions);
runActions(automation.actions);
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { createNotificationFromText } from '../../components-lib/notification/factory';
import { ConditionStateChange } from '../../conditions/types';
import { ConditionStateChange } from '../../condition-trigger/conditions/types';
import { localize } from '../../localize/localize';
import { Timer } from '../../utils/timer';
import { getStreamCameraID } from '../../view/substream';
@@ -2,7 +2,7 @@ import { maxBy, throttle } from 'lodash-es';
import { CameraEvent } from '../camera-manager/types';
import { isTriggeredState } from '../ha/is-triggered-state';
import { Timer } from '../utils/timer';
import { CardTriggersAPI } from './types';
import { CardCameraTriggersAPI } from './types';
interface CameraTriggerState {
// The time of the most recent trigger event. Used to determine the most
@@ -26,15 +26,15 @@ interface CameraTriggerState {
untriggerForceTimer?: Timer;
}
export class TriggersManager {
private _api: CardTriggersAPI;
export class CameraTriggersManager {
private _api: CardCameraTriggersAPI;
private _states: Map<string, CameraTriggerState> = new Map();
private _throttledTriggerAction = throttle(this._triggerAction.bind(this), 1000, {
trailing: true,
});
constructor(api: CardTriggersAPI) {
constructor(api: CardCameraTriggersAPI) {
this._api = api;
}
+1 -1
View File
@@ -188,7 +188,7 @@ export class CardElementManager {
// reconnection, to ensure the state subscription/unsubscription works
// correctly and triggers that changed while detached are picked up.
// Reset trigger state first to stop stale timers and clear condition state.
this._api.getTriggersManager().reset();
this._api.getCameraTriggersManager().reset();
this._api.getCallManager().uninitialize();
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
@@ -22,9 +22,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
const automations: TaggedAutomation[] = [
{
conditions: [
triggers: [
{
condition: 'config' as const,
trigger: 'config' as const,
paths: ['cameras', 'remote_control.entities.camera'],
},
],
@@ -38,9 +38,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
tag: automationTag,
},
{
conditions: [
triggers: [
{
condition: 'camera' as const,
trigger: 'camera' as const,
},
],
actions: [
@@ -63,9 +63,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
// this one, as automations only run *after* the card is initialized (and
// it very likely will not yet be). Instead, wait to be initialized, then
// set the camera.
conditions: [
triggers: [
{
condition: 'initialized' as const,
trigger: 'initialized' as const,
},
],
actions: [
@@ -81,15 +81,15 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
tag: automationTag,
},
{
conditions: [
triggers: [
{
condition: 'state' as const,
entity: cameraControlEntity,
trigger: 'state' as const,
entity_id: cameraControlEntity,
},
],
actions: [
// When the entity state changes, updated the selected option.
createCameraAction('{{ advanced_camera_card.trigger.state.to }}'),
createCameraAction('{{ trigger.to_state.state }}'),
],
tag: automationTag,
},
@@ -64,9 +64,9 @@ const convertKeyboardShortcutsToAutomations = (
}
automations.push({
conditions: [
triggers: [
{
condition: 'key' as const,
trigger: 'key' as const,
key: shortcut.key,
state: 'down',
shift: shortcut.shift,
@@ -85,9 +85,9 @@ const convertKeyboardShortcutsToAutomations = (
});
automations.push({
conditions: [
triggers: [
{
condition: 'key' as const,
trigger: 'key' as const,
key: shortcut.key,
state: 'up',
},
@@ -105,9 +105,9 @@ const convertKeyboardShortcutsToAutomations = (
const homeShortcut = shortcuts.ptz_home;
if (homeShortcut) {
automations.push({
conditions: [
triggers: [
{
condition: 'key' as const,
trigger: 'key' as const,
key: homeShortcut.key,
state: 'down',
shift: homeShortcut.shift,
@@ -1,6 +1,6 @@
import { get, merge } from 'lodash-es';
import { ConditionsManager } from '../../conditions/conditions-manager';
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
import { ConditionsManager } from '../../condition-trigger/conditions/conditions-manager';
import { ConditionStateManagerReadonlyInterface } from '../../condition-trigger/conditions/types';
import {
copyConfig,
deleteConfigValue,
+7 -7
View File
@@ -1,6 +1,6 @@
import { ReactiveController } from 'lit';
import { CameraManager } from '../camera-manager/manager';
import { ConditionStateManager } from '../conditions/state-manager';
import { ConditionStateManager } from '../condition-trigger/conditions/state-manager';
import { AdvancedCameraCardConfig } from '../config/schema/types';
import { DeviceRegistryManager } from '../ha/registry/device';
import { DeviceCache } from '../ha/registry/device/types';
@@ -11,6 +11,7 @@ import { LovelaceCardEditor } from '../ha/types';
import { ActionsManager } from './actions/actions-manager';
import { AutomationsManager } from './automations-manager';
import { CallManager } from './call/manager';
import { CameraTriggersManager } from './camera-triggers-manager';
import { CameraURLManager } from './camera-url-manager';
import {
CardElementManager,
@@ -40,11 +41,11 @@ import { QueryStringManager } from './query-string-manager';
import { StatusBarItemManager } from './status-bar-item-manager';
import { StyleManager } from './style-manager';
import { TemplateRenderer } from './templates';
import { TriggersManager } from './triggers-manager';
import {
CardActionsManagerAPI,
CardAutomationsAPI,
CardCameraAPI,
CardCameraTriggersAPI,
CardCameraURLAPI,
CardConditionAPI,
CardConfigAPI,
@@ -66,7 +67,6 @@ import {
CardPIPAPI,
CardQueryStringAPI,
CardStyleAPI,
CardTriggersAPI,
CardViewAPI,
} from './types';
import { ViewItemManager } from './view/item-manager';
@@ -98,7 +98,7 @@ export class CardController
CardNotificationAPI,
CardQueryStringAPI,
CardStyleAPI,
CardTriggersAPI,
CardCameraTriggersAPI,
CardViewAPI,
ReactiveController
{
@@ -137,7 +137,7 @@ export class CardController
private _queryStringManager = new QueryStringManager(this);
private _statusBarItemManager = new StatusBarItemManager(this);
private _styleManager = new StyleManager(this);
private _triggersManager = new TriggersManager(this);
private _cameraTriggersManager = new CameraTriggersManager(this);
private _viewManager = new ViewManager(this);
private _viewItemManager = new ViewItemManager(this);
@@ -305,8 +305,8 @@ export class CardController
return this._styleManager;
}
public getTriggersManager(): TriggersManager {
return this._triggersManager;
public getCameraTriggersManager(): CameraTriggersManager {
return this._cameraTriggersManager;
}
public getViewManager(): ViewManager {
+2 -2
View File
@@ -55,9 +55,9 @@ export class DefaultManager {
this._api.getAutomationsManager().addAutomations([
{
actions: [createGeneralAction('default')],
conditions: [
triggers: [
{
condition: 'interaction' as const,
trigger: 'interaction' as const,
interaction: false,
},
],
+1 -1
View File
@@ -1,4 +1,4 @@
import { ConditionState } from '../../conditions/types';
import { ConditionState } from '../../condition-trigger/conditions/types';
import { FolderConfig, FolderType, folderTypeSchema } from '../../config/schema/folders';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
+1 -1
View File
@@ -1,6 +1,6 @@
import { sub } from 'date-fns';
import { NonEmptyTuple } from 'type-fest';
import { ConditionState } from '../../../conditions/types';
import { ConditionState } from '../../../condition-trigger/conditions/types';
import {
FolderConfig,
folderTypeSchema,
@@ -1,5 +1,5 @@
import { sub } from 'date-fns';
import { ConditionState } from '../../../conditions/types';
import { ConditionState } from '../../../condition-trigger/conditions/types';
import {
DateMatcher,
Matcher,
+1 -1
View File
@@ -1,5 +1,5 @@
import { cloneDeep } from 'lodash-es';
import { ConditionState } from '../../conditions/types';
import { ConditionState } from '../../condition-trigger/conditions/types';
import { FolderConfig, FolderConfigWithoutID } from '../../config/schema/folders';
import { localize } from '../../localize/localize';
import { hasUnsupportedFilters } from '../../query-source.js';
+1 -1
View File
@@ -1,5 +1,5 @@
import { NonEmptyTuple } from 'type-fest';
import { ConditionState } from '../../conditions/types';
import { ConditionState } from '../../condition-trigger/conditions/types';
import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types';
@@ -1,4 +1,4 @@
import { ConditionStateChange } from '../../../conditions/types';
import { ConditionStateChange } from '../../../condition-trigger/conditions/types';
import { WebkitHTMLVideoElement } from '../../../types';
import { Timer } from '../../../utils/timer';
import { FullscreenProviderBase } from '../provider';
+1 -1
View File
@@ -1,6 +1,6 @@
import { HassEvent } from 'home-assistant-js-websocket';
import { HomeAssistant } from '../../ha/types';
import { KeyedSubscriptionManager } from '../../utils/keyed-subscription-manager';
import { KeyedSubscriptionManager } from '../../utils/concurrency/keyed-subscription-manager';
export interface EventSubscriptionRequest {
event_type: string;
@@ -160,7 +160,7 @@ export class InitializationManager {
this._initializer.initializeIfNecessary(
InitializationAspect.INITIAL_TRIGGER,
async () => {
await this._api.getTriggersManager().handleInitialCameraTriggers();
await this._api.getCameraTriggersManager().handleInitialCameraTriggers();
// Force a card update to continue the initialization.
this._api.getCardElementManager().update();
+2
View File
@@ -2,6 +2,7 @@ import { CardIssueManagerAPI } from '../types';
import { IssueManager } from './issue-manager';
import { ConfigErrorIssue } from './issues/config-error';
import { ConfigUpgradeIssue } from './issues/config-upgrade';
import { ConfigUpgradeFailureIssue } from './issues/config-upgrade-failure';
import { ConnectionIssue } from './issues/connection';
import { InitializationIssue } from './issues/initialization';
import { LegacyResourceIssue } from './issues/legacy-resource';
@@ -19,6 +20,7 @@ export const createIssueManager = (api: CardIssueManagerAPI): IssueManager => {
// full-card issue. Register broader/more critical issues first.
manager.addIssue(new ConfigErrorIssue());
manager.addIssue(new ConfigUpgradeIssue(api));
manager.addIssue(new ConfigUpgradeFailureIssue(api));
manager.addIssue(new ViewIncompatibleIssue(api));
manager.addIssue(new ConnectionIssue());
manager.addIssue(new InitializationIssue(api));
+1 -1
View File
@@ -1,5 +1,5 @@
import type { IssueTriggerContext } from 'issue';
import { ConditionStateChange } from '../../conditions/types';
import { ConditionStateChange } from '../../condition-trigger/conditions/types';
import { isActionAllowedBasedOnInteractionState } from '../../utils/interaction-mode';
import { Timer } from '../../utils/timer';
import { CardIssueManagerAPI } from '../types';
@@ -0,0 +1,50 @@
import { hasConfigUpgradeFailures } from '../../../config/management.js';
import { TROUBLESHOOTING_CONFIG_UPGRADE_FAILURE_URL } from '../../../const.js';
import { localize } from '../../../localize/localize.js';
import { CardIssueManagerAPI } from '../../types';
import { Issue, IssueDescription } from '../types';
// Raised when the configuration upgrade could not faithfully upgrade part of
// the config.
export class ConfigUpgradeFailureIssue implements Issue {
public readonly key = 'config_upgrade_failure' as const;
private _api: CardIssueManagerAPI;
private _hasFailure = false;
constructor(api: CardIssueManagerAPI) {
this._api = api;
}
public async detectStatic(): Promise<void> {
this._hasFailure = hasConfigUpgradeFailures(
this._api.getConfigManager().getRawConfig(),
);
}
public hasIssue(): boolean {
return this._hasFailure;
}
public getIssue(): IssueDescription | null {
if (!this._hasFailure) {
return null;
}
return {
icon: 'mdi:update',
severity: 'medium',
notification: {
heading: {
text: localize('issues.config_upgrade_failure.heading'),
icon: 'mdi:update',
severity: 'medium',
},
body: { text: localize('issues.config_upgrade_failure.text') },
link: {
url: TROUBLESHOOTING_CONFIG_UPGRADE_FAILURE_URL,
title: localize('issues.troubleshooting_guide'),
},
},
};
}
}
@@ -1,5 +1,5 @@
import { STATE_RUNNING } from 'home-assistant-js-websocket';
import { ConditionState } from '../../../conditions/types.js';
import { ConditionState } from '../../../condition-trigger/conditions/types.js';
import { localize } from '../../../localize/localize.js';
import { Issue, IssueDescription } from '../types.js';
@@ -1,5 +1,5 @@
import type { IssueTriggerContext } from 'issue';
import { ConditionState } from '../../../conditions/types.js';
import { ConditionState } from '../../../condition-trigger/conditions/types.js';
import { Notification } from '../../../config/schema/actions/types.js';
import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js';
import { localize } from '../../../localize/localize.js';
+1 -1
View File
@@ -1,6 +1,6 @@
import type { IssueTriggerContext } from 'issue';
import { summarizeNotification } from '../../components-lib/notification/summarize';
import { ConditionState } from '../../conditions/types';
import { ConditionState } from '../../condition-trigger/conditions/types';
import { Notification } from '../../config/schema/actions/types';
import { HomeAssistant } from '../../ha/types';
import { isTruthy } from '../../utils/basic';
+2 -1
View File
@@ -1,5 +1,5 @@
import type { IssueTriggerContext } from 'issue';
import { ConditionState } from '../../conditions/types';
import { ConditionState } from '../../condition-trigger/conditions/types';
import { Notification } from '../../config/schema/actions/types';
import { HomeAssistant } from '../../ha/types';
import { Severity } from '../../severity';
@@ -7,6 +7,7 @@ import { Severity } from '../../severity';
export type IssueKey =
| 'config_error'
| 'config_upgrade'
| 'config_upgrade_failure'
| 'connection'
| 'initialization'
| 'legacy_resource'
+1 -1
View File
@@ -1,4 +1,4 @@
import { ConditionStateChange } from '../conditions/types';
import { ConditionStateChange } from '../condition-trigger/conditions/types';
import { PIPElement } from '../types';
import { CardPIPAPI } from './types';
+14 -21
View File
@@ -1,28 +1,20 @@
import { HASS, renderTemplate } from 'ha-nunjucks/dist';
import { ConditionState, ConditionsTriggerData } from '../../conditions/types';
import { ConditionState } from '../../condition-trigger/conditions/types';
import { TriggerData } from '../../condition-trigger/triggers/types';
import { HomeAssistant } from '../../ha/types';
interface TemplateMediaData {
title: string;
is_folder: boolean;
}
interface TemplateContextInternal {
camera?: string;
view?: string;
trigger?: ConditionsTriggerData;
media?: TemplateMediaData;
}
import { isRecord } from '../../utils/basic';
import { TemplateACCNamespace, TemplateMediaData } from './types';
interface TemplateContext {
advanced_camera_card: TemplateContextInternal;
acc: TemplateACCNamespace;
// Convenient alias.
acc: TemplateContextInternal;
// The HA-native top-level `trigger`, set only when a trigger fired.
trigger?: TriggerData;
}
interface TemplateRenderOptions {
conditionState?: ConditionState;
triggerData?: ConditionsTriggerData;
triggerData?: TriggerData;
mediaData?: TemplateMediaData;
}
@@ -45,22 +37,23 @@ export class TemplateRenderer {
if (
!options?.conditionState?.camera &&
!options?.conditionState?.view &&
!options?.conditionState?.config &&
!options?.triggerData &&
!options?.mediaData
) {
return;
}
const advancedCameraCardContext: TemplateContextInternal = {
const acc: TemplateACCNamespace = {
...(options?.conditionState?.camera && { camera: options.conditionState.camera }),
...(options?.conditionState?.view && { view: options.conditionState.view }),
...(options?.triggerData && { trigger: options.triggerData }),
...(options?.conditionState?.config && { config: options.conditionState.config }),
...(options?.mediaData && { media: options.mediaData }),
};
return {
acc: advancedCameraCardContext,
advanced_camera_card: advancedCameraCardContext,
acc,
...(options?.triggerData && { trigger: options.triggerData }),
};
}
@@ -81,7 +74,7 @@ export class TemplateRenderer {
return data.map((item) =>
this._renderTemplateRecursively(hass, item, templateContext),
);
} else if (typeof data === 'object' && data !== null) {
} else if (isRecord(data)) {
const result = {};
for (const key in data) {
result[key] = this._renderTemplateRecursively(hass, data[key], templateContext);
+22
View File
@@ -0,0 +1,22 @@
import { AdvancedCameraCardConfig } from '../../config/schema/types';
// The card state exposed via the `acc` namespace AND as the
// `trigger.from_acc`/`to_acc` before/after snapshots (the card analogue of HA's
// full `trigger.from_state`/`to_state`).
export interface TemplateAdvancedCameraCardState {
camera?: string;
view?: string;
config?: AdvancedCameraCardConfig;
}
export interface TemplateMediaData {
title: string;
is_folder: boolean;
}
// The ambient `acc` namespace: card state plus `media` (the item currently being
// templated, e.g. a folder-match candidate). `media` is not card state, so it is
// not part of the shared snapshot type above.
export interface TemplateACCNamespace extends TemplateAdvancedCameraCardState {
media?: TemplateMediaData;
}
+11 -11
View File
@@ -1,5 +1,5 @@
import type { CameraManager } from '../camera-manager/manager';
import type { ConditionStateManager } from '../conditions/state-manager';
import type { ConditionStateManager } from '../condition-trigger/conditions/state-manager';
import type { Automation } from '../config/schema/automations';
import type { DeviceRegistryManager } from '../ha/registry/device';
import type { EntityRegistryManager } from '../ha/registry/entity/types';
@@ -8,6 +8,7 @@ import type { EffectsManagerInterface } from '../types';
import type { ActionsManager } from './actions/actions-manager';
import type { AutomationsManager } from './automations-manager';
import type { CallManager } from './call/manager';
import type { CameraTriggersManager } from './camera-triggers-manager';
import type { CameraURLManager } from './camera-url-manager';
import type { CardElementManager } from './card-element-manager';
import type { ConfigManager } from './config/config-manager';
@@ -29,7 +30,6 @@ import type { PIPManager } from './pip-manager';
import type { QueryStringManager } from './query-string-manager';
import type { StatusBarItemManager } from './status-bar-item-manager';
import type { StyleManager } from './style-manager';
import type { TriggersManager } from './triggers-manager';
import type { ViewItemManager } from './view/item-manager';
import type { ViewManager } from './view/view-manager';
@@ -61,7 +61,7 @@ export interface CardActionsAPI {
getPIPManager(): PIPManager;
getIssueManager(): IssueManager;
getStatusBarItemManager(): StatusBarItemManager;
getTriggersManager(): TriggersManager;
getCameraTriggersManager(): CameraTriggersManager;
getViewItemManager(): ViewItemManager;
getViewManager(): ViewManager;
}
@@ -94,7 +94,7 @@ export interface CardCameraAPI {
getEntityRegistryManager(): EntityRegistryManager;
getHASSManager(): HASSManager;
getResolvedMediaCache(): ResolvedMediaCache;
getTriggersManager(): TriggersManager;
getCameraTriggersManager(): CameraTriggersManager;
}
export interface CardCameraURLAPI {
@@ -139,7 +139,7 @@ export interface CardDefaultManagerAPI {
getConfigManager(): ConfigManager;
getHASSManager(): HASSManager;
getInteractionManager(): InteractionManager;
getTriggersManager(): TriggersManager;
getCameraTriggersManager(): CameraTriggersManager;
getViewManager(): ViewManager;
}
@@ -170,7 +170,7 @@ export interface CardElementAPI {
getPIPManager(): PIPManager;
getIssueManager(): IssueManager;
getQueryStringManager(): QueryStringManager;
getTriggersManager(): TriggersManager;
getCameraTriggersManager(): CameraTriggersManager;
getViewManager(): ViewManager;
}
@@ -209,7 +209,7 @@ export interface CardHASSAPI {
getInteractionManager(): InteractionManager;
getMediaPlayerManager(): MediaPlayerManager;
getStyleManager(): StyleManager;
getTriggersManager(): TriggersManager;
getCameraTriggersManager(): CameraTriggersManager;
getViewManager(): ViewManager;
}
@@ -230,7 +230,7 @@ export interface CardInitializerAPI {
getIssueManager(): IssueManager;
getQueryStringManager(): QueryStringManager;
getResolvedMediaCache(): ResolvedMediaCache;
getTriggersManager(): TriggersManager;
getCameraTriggersManager(): CameraTriggersManager;
getViewManager(): ViewManager;
}
@@ -239,7 +239,7 @@ export interface CardInteractionAPI {
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
getStyleManager(): StyleManager;
getTriggersManager(): TriggersManager;
getCameraTriggersManager(): CameraTriggersManager;
getViewManager(): ViewManager;
}
@@ -314,7 +314,7 @@ export interface CardStyleAPI {
getViewManager(): ViewManager;
}
export interface CardTriggersAPI {
export interface CardCameraTriggersAPI {
getCallManager(): CallManager;
getCameraManager(): CameraManager;
getConditionStateManager(): ConditionStateManager;
@@ -339,7 +339,7 @@ export interface CardViewAPI {
getIssueManager(): IssueManager;
getQueryStringManager(): QueryStringManager;
getStyleManager(): StyleManager;
getTriggersManager(): TriggersManager;
getCameraTriggersManager(): CameraTriggersManager;
}
// *************************************************************************