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
+1 -1
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { HomeAssistant } from '../../ha/types';
import { KeyedSubscriptionManager } from '../../utils/keyed-subscription-manager';
import { KeyedSubscriptionManager } from '../../utils/concurrency/keyed-subscription-manager';
import {
FrigateEventChange,
FrigateReviewChange,
+2 -1
View File
@@ -203,7 +203,8 @@ export class CameraManager {
const engine = engineType
? engines.get(engineType) ??
(await this._engineFactory.createEngine(engineType, {
eventCallback: (ev) => this._api.getTriggersManager().handleCameraEvent(ev),
eventCallback: (ev) =>
this._api.getCameraTriggersManager().handleCameraEvent(ev),
stateWatcher: this._api.getHASSManager().getStateWatcher(),
eventWatcher: this._api.getHASSManager().getEventWatcher(),
resolvedMediaCache: this._api.getResolvedMediaCache(),
+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;
}
// *************************************************************************
+2 -2
View File
@@ -25,7 +25,7 @@ import './components/status-bar';
import './components/thumbnail-carousel.js';
import './components/views.js';
import { AdvancedCameraCardViews } from './components/views.js';
import { ConditionStateManagerGetEvent } from './conditions/state-manager-via-event.js';
import { ConditionStateManagerGetEvent } from './condition-trigger/conditions/state-manager-via-event.js';
import { StatusBarItem } from './config/schema/actions/types.js';
import { MenuItem } from './config/schema/elements/custom/menu/types.js';
import { AdvancedCameraCardConfig } from './config/schema/types.js';
@@ -457,7 +457,7 @@ class AdvancedCameraCard extends LitElement {
.locked=${this._controller.getLockManager().isLocked()}
.conditionStateManager=${this._controller.getConditionStateManager()}
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
? this._controller.getTriggersManager().getTriggeredCameraIDs()
? this._controller.getCameraTriggersManager().getTriggeredCameraIDs()
: undefined}
.deviceRegistryManager=${this._controller.getDeviceRegistryManager()}
.issues=${this._controller
+2 -1
View File
@@ -8,7 +8,7 @@ import { MENU_PRIORITY_MAX } from '../config/schema/common/const.js';
import type { MenuItem } from '../config/schema/elements/custom/menu/types.js';
import type { MenuConfig } from '../config/schema/menu.js';
import type { Interaction } from '../types.js';
import { getActionConfigGivenAction } from '../utils/action';
import { getActionConfigGivenAction, isStandardAction } from '../utils/action';
import { arrayify, isTruthy } from '../utils/basic.js';
import { AutoHideState, isAutoHidden as evaluateAutoHidden } from './auto-hide.js';
@@ -225,6 +225,7 @@ export class MenuController {
private _isMenuToggleAction(action: ActionConfig): boolean {
return (
isStandardAction(action) &&
action.action === 'fire-dom-event' &&
action.advanced_camera_card_action === 'menu_toggle'
);
+1 -1
View File
@@ -19,7 +19,7 @@ import { FoldersManager } from '../../card-controller/folders/manager';
import { ViewItemManager } from '../../card-controller/view/item-manager';
import { MergeContextViewModifier } from '../../card-controller/view/modifiers/merge-context';
import { ViewManagerEpoch } from '../../card-controller/view/types';
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
import { ConditionStateManagerReadonlyInterface } from '../../condition-trigger/conditions/types';
import { CameraConfig } from '../../config/schema/cameras';
import { AdvancedCameraCardView } from '../../config/schema/common/const';
import { ThumbnailsControlBaseConfig } from '../../config/schema/common/controls/thumbnails';
+1 -1
View File
@@ -11,7 +11,7 @@ import { RecordingSegment } from '../../camera-manager/types';
import { capEndDate } from '../../camera-manager/utils/cap-end-date';
import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly';
import { FoldersManager } from '../../card-controller/folders/manager';
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
import { ConditionStateManagerReadonlyInterface } from '../../condition-trigger/conditions/types';
import { FolderConfig } from '../../config/schema/folders';
import { errorToConsole } from '../../utils/basic.js';
import { ViewItem, ViewMedia } from '../../view/item';
+3 -3
View File
@@ -10,9 +10,9 @@ import { customElement, property, state } from 'lit/decorators.js';
import { isEqual } from 'lodash-es';
import { IssueTriggerEventData } from '../card-controller/issues/types.js';
import { TemplateRenderer } from '../card-controller/templates/index.js';
import { ConditionsManager } from '../conditions/conditions-manager.js';
import { getConditionStateManagerViaEvent } from '../conditions/state-manager-via-event.js';
import { ConditionStateManager } from '../conditions/state-manager.js';
import { ConditionsManager } from '../condition-trigger/conditions/conditions-manager.js';
import { getConditionStateManagerViaEvent } from '../condition-trigger/conditions/state-manager-via-event.js';
import { ConditionStateManager } from '../condition-trigger/conditions/state-manager.js';
import {
StatusBarIcon,
StatusBarImage,
+1 -1
View File
@@ -22,7 +22,7 @@ import {
navigateToMedia,
navigateUp,
} from '../../components-lib/navigation.js';
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types.js';
import { ConditionStateManagerReadonlyInterface } from '../../condition-trigger/conditions/types.js';
import { MediaGalleryConfig } from '../../config/schema/media-gallery.js';
import { CardWideConfig } from '../../config/schema/types.js';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const.js';
+1 -1
View File
@@ -11,7 +11,7 @@ import { CameraManager } from '../camera-manager/manager.js';
import { FoldersManager } from '../card-controller/folders/manager.js';
import { ViewItemManager } from '../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js';
import { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types.js';
import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js';
import { MiniTimelineControlConfig } from '../config/schema/common/controls/timeline.js';
import { CardWideConfig } from '../config/schema/types.js';
+1 -1
View File
@@ -19,7 +19,7 @@ import {
ThumbnailDataRequestEvent,
TimelineItemClickAction,
} from '../components-lib/timeline/types';
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
import { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types';
import { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails';
import { TimelineCoreConfig } from '../config/schema/common/controls/timeline';
import { CardWideConfig } from '../config/schema/types';
+1 -1
View File
@@ -4,7 +4,7 @@ import { CameraManager } from '../camera-manager/manager';
import { FoldersManager } from '../card-controller/folders/manager';
import { ViewItemManager } from '../card-controller/view/item-manager';
import { ViewManagerEpoch } from '../card-controller/view/types';
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
import { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types';
import { TimelineConfig } from '../config/schema/timeline';
import { CardWideConfig } from '../config/schema/types';
import { HomeAssistant } from '../ha/types';
+1 -1
View File
@@ -16,7 +16,7 @@ import { MicrophoneState } from '../card-controller/types.js';
import { ViewItemManager } from '../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import { CallSession } from '../card-controller/call/types.js';
import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js';
import { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types.js';
import { AdvancedCameraCardConfig, CardWideConfig } from '../config/schema/types.js';
import { RawAdvancedCameraCardConfig } from '../config/types.js';
import { DeviceRegistryManager } from '../ha/registry/device/index.js';
+70
View File
@@ -0,0 +1,70 @@
# `condition-trigger`
The runtime behind `automations:`, `overrides:` and conditional
picture-`elements:`, built to mirror Home Assistant's conditions and triggers.
User-facing reference: [`conditions-triggers.md`](../../docs/configuration/conditions-triggers.md).
## Condition vs trigger
The same type (`state`, `camera`, ...) exists as both, but they are opposite shapes:
| | Condition | Trigger |
| ------- | ------------------------------------------------- | -------------------------------------- |
| Asks | _"is this true right now?"_ | _"did this just become true?"_ |
| Is a | level / predicate, pulled via `evaluate()` | edge / event, pushed via `subscribe()` |
| Used in | `automations` `conditions`, `overrides`, elements | `automations` `triggers` only |
Both read one source of truth, the **`ConditionStateManager`** -- the card's
live state (`camera`/`view`/`config`/`hass`/...), which notifies on change (the
lone exception is `screen`, which watches a `window.matchMedia` query). The Zod
schema
([`config/schema/condition-trigger/`](../config/schema/condition-trigger/))
shares each type's fields between its condition and trigger (`common/`) so the
two cannot drift.
## Conditions
Each type has a pure level-predicate evaluator (`evaluate(state) -> result`),
built by `createConditionEvaluator`. An evaluator may also declare
`externalSources` -- change sources outside `ConditionState` (currently only
`screen`'s `matchMedia`, via a `MediaQueryWatcher`) -- which `ConditionsManager`
subscribes to so a change there triggers a re-evaluation. The manager ANDs a set
of evaluators and notifies when the combined result flips; it backs
`overrides`/`elements` and the automation ongoing-`conditions` pull (below).
## The bridge
A type's meaning lives in exactly one place -- its condition evaluator. A
card-state trigger reuses that same evaluator as a point-in-time value-filter,
built directly from the trigger by `createConditionEvaluatorForTrigger` (the
trigger and condition schemas share a `common/` base, so no discriminator-swap
or cast). One definition of meaning, two readings: the condition asks the
predicate, the trigger watches for change and filters it through the predicate.
## Triggers: four kinds
`createTriggerEvaluator` picks one. Each emits a `TriggerData` payload (the
`trigger.*` template variable); stock triggers report their HA `platform`, card
triggers report `platform: acc` + the kind in `type`.
1. **Stock entity** (`state`, `numeric_state`) -- `EntityStateTriggerBase`:
per-`entity_id` fan-out, HA `from_state`/`to_state`, `for:` via a `Timer`.
2. **Stock template** (`template`) -- the non-true -> true edge of `value_template`.
3. **Screen** (`screen`) -- `ScreenTrigger`: watches a `matchMedia` query, whose
state lives outside `ConditionState`, so it owns a shared `MediaQueryWatcher`
(the same watcher the screen condition uses) and fires on the rising edge of
the match.
4. **Card-state** (every other type: `camera`, `view`, `config`, `fullscreen`,
...) -- `ConditionStateTriggerBase`: subscribe to the
`ConditionStateManager`, fire when the watched field (`_getValue`) changes,
and emit `from_acc`/`to_acc` snapshots. A value filters the change through
the matching condition (the bridge); no value fires on any change; `config`
is trigger-only (no matching condition).
## Automations: push, then pull
`AutomationsManager` runs a `TriggersManager` per automation and, when a trigger
pushes, **pull-evaluates** the ongoing `conditions` against the current state
(this matches Home Assistant actions). The state store updates _before_
dispatching, so a pulled condition already sees the triggering change -- no
ordering needed between the two.
@@ -0,0 +1,33 @@
import { TemplateRenderer } from '../../card-controller/templates';
import { ConditionState } from '../conditions/types';
// The shared `enabled` gate for triggers and conditions (equivalent to HA's
// `vol.Any(boolean, template)`): a boolean, or a template rendered against the
// current state. Returns whether the trigger/condition is active.
//
// `enabledWithoutHass` is the fallback when a template `enabled` cannot be
// rendered (no hass yet, e.g. at startup). It differs by caller because
// "disabled" has opposite consequences: a disabled *trigger* simply does not
// fire (so triggers fail closed -- pass `false`), whereas a disabled
// *condition* is skipped (so the condition may evaluate to `true`)
export const isEnabled = (
templateRenderer: TemplateRenderer,
enabled?: boolean | string,
state?: ConditionState,
enabledWithoutHass = true,
): boolean => {
if (enabled === undefined) {
return true;
}
if (typeof enabled === 'boolean') {
return enabled;
}
if (!state?.hass) {
return enabledWithoutHass;
}
return (
templateRenderer.renderRecursively(state.hass, enabled, {
conditionState: state,
}) === true
);
};
@@ -0,0 +1,32 @@
export type MediaQueryWatcherUnsubscribeCallback = () => void;
// Watches a single CSS media query via `window.matchMedia`. The shared change
// source for the `screen` condition and trigger, whose match state lives
// outside the card's `ConditionState`.
export class MediaQueryWatcher {
private _query: string;
private _mediaQuery: MediaQueryList | null = null;
private _callback: (() => void) | null = null;
constructor(query: string) {
this._query = query;
}
public matches(): boolean {
return window.matchMedia(this._query).matches;
}
public subscribe(callback: () => void): MediaQueryWatcherUnsubscribeCallback {
this._callback = callback;
this._mediaQuery = window.matchMedia(this._query);
this._mediaQuery.addEventListener('change', this._handler);
return (): void => {
this._mediaQuery?.removeEventListener('change', this._handler);
this._mediaQuery = null;
this._callback = null;
};
}
private _handler = (): void => this._callback?.();
}
@@ -0,0 +1,75 @@
import { TemplateRenderer } from '../../card-controller/templates';
import { NumericStateBase } from '../../config/schema/condition-trigger/common/numeric-state';
import { ConditionState } from '../conditions/types';
// The numeric value of `entityID` to compare: the rendered `value_template`,
// else the `attribute`, else the state. Returns null when the entity is absent
// or the value is non-numeric (the cases where HA raises a ConditionError).
export const readNumericStateValue = (
entityID: string,
state: ConditionState,
config: NumericStateBase,
templateRenderer: TemplateRenderer,
): number | null => {
const hass = state.hass;
if (!hass) {
return null;
}
const stateObj = hass.states?.[entityID];
if (!stateObj) {
return null;
}
let rawValue: unknown;
if (config.value_template) {
rawValue = templateRenderer.renderRecursively(hass, config.value_template, {
conditionState: state,
});
} else if (config.attribute !== undefined) {
rawValue = stateObj.attributes?.[config.attribute];
} else {
rawValue = stateObj.state;
}
const value = Number(rawValue);
return Number.isFinite(value) ? value : null;
};
// Whether `entityID`'s numeric value currently satisfies the `above`/`below`
// thresholds. A threshold is a number, or an entity id whose state supplies it;
// an unspecified threshold imposes no constraint and an unresolvable one fails.
// Shared by the numeric_state condition and trigger, which match identically.
export const matchesNumericState = (
entityID: string,
state: ConditionState,
config: NumericStateBase,
templateRenderer: TemplateRenderer,
): boolean => {
const hass = state.hass;
if (!hass) {
return false;
}
const value = readNumericStateValue(entityID, state, config, templateRenderer);
if (value === null) {
return false;
}
const checkBound = (
compare: (value: number, bound: number) => boolean,
threshold?: number | string,
): boolean => {
if (threshold === undefined) {
return true;
}
const bound =
typeof threshold === 'number'
? threshold
: Number(hass.states?.[threshold]?.state);
return Number.isFinite(bound) && compare(value, bound);
};
return (
checkBound((v, bound) => v > bound, config.above) &&
checkBound((v, bound) => v < bound, config.below)
);
};
@@ -0,0 +1,63 @@
import { TemplateRenderer } from '../../card-controller/templates';
import { TimePeriod } from '../../config/schema/common/time-period';
import { isRecord } from '../../utils/basic';
import { ConditionState } from '../conditions/types';
// Parses a Home Assistant time-period value (a condition/trigger `for:`) to
// seconds, matching HA's `cv.time_period`:
// - a number, or a bare numeric string, is a count of seconds;
// - a colon string is `HH:MM` or `HH:MM:SS` — HA reads TWO parts as
// hours:minutes (not minutes:seconds);
// - a `{days, hours, minutes, seconds, milliseconds}` dict (each field a
// number or a numeric string, e.g. once a template field has been rendered).
// Accepts `unknown` so a freshly-rendered value can be parsed directly; returns
// null when unparseable or negative (HA `for:` requires a positive period).
const parseTimePeriodToSeconds = (value: unknown): number | null => {
const num = (field: unknown): number => Number(field ?? 0);
let seconds: number;
if (typeof value === 'number') {
seconds = value;
} else if (typeof value === 'string') {
if (value.includes(':')) {
const parts = value.split(':');
if (parts.length < 2 || parts.length > 3 || parts.some((part) => !part.trim())) {
return null;
}
const [hours, minutes, secs = 0] = parts.map(Number);
seconds = hours * 3600 + minutes * 60 + secs;
} else if (!value.trim()) {
return null;
} else {
seconds = Number(value);
}
} else if (isRecord(value)) {
seconds =
num(value.days) * 86400 +
num(value.hours) * 3600 +
num(value.minutes) * 60 +
num(value.seconds) +
num(value.milliseconds) / 1000;
} else {
return null;
}
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
};
// Renders any templates within a `for:` time period (HA's
// `cv.positive_time_period_template`) against the current state, then parses it
// to seconds. The whole value or any dict field may be a template; a
// template-free value renders to itself. Without `hass` the value cannot be
// rendered, so it is parsed as-is (a literal duration still works; a template
// yields null).
export const renderTimePeriodToSeconds = (
templateRenderer: TemplateRenderer,
value: TimePeriod,
conditionState?: ConditionState,
): number | null => {
if (!conditionState?.hass) {
return parseTimePeriodToSeconds(value);
}
return parseTimePeriodToSeconds(
templateRenderer.renderRecursively(conditionState.hass, value, { conditionState }),
);
};
@@ -0,0 +1,122 @@
import { TemplateRenderer } from '../../card-controller/templates';
import { Condition } from '../../config/schema/condition-trigger/conditions/types';
import { isEnabled } from '../common/is-enabled';
import {
ConditionEvaluator,
ExternalInvalidationUnsubscribeCallback,
} from './conditions/types';
import { createConditionEvaluator } from './factory';
import {
ConditionsEvaluationResult,
ConditionsListener,
ConditionsManagerReadonlyInterface,
ConditionStateChange,
ConditionStateManagerReadonlyInterface,
} from './types';
// A condition evaluator paired with its config, so `enabled` can be re-checked
// against the config each time conditions are evaluated.
interface ManagedCondition {
config: Condition;
evaluator: ConditionEvaluator;
}
/**
* A class to evaluate an array of conditions, and notify listeners when the
* evaluation result changes.
*/
export class ConditionsManager implements ConditionsManagerReadonlyInterface {
private _stateManager: ConditionStateManagerReadonlyInterface | null;
private _templateRenderer = new TemplateRenderer();
private _conditions: ManagedCondition[];
private _listeners: ConditionsListener[] = [];
private _evaluation: ConditionsEvaluationResult = { result: false };
private _unsubscribeCallbacks: ExternalInvalidationUnsubscribeCallback[] = [];
constructor(
conditions: Condition[],
stateManager?: ConditionStateManagerReadonlyInterface | null,
) {
const context = { templateRenderer: this._templateRenderer };
this._conditions = conditions.map((config) => ({
config,
evaluator: createConditionEvaluator(config, context),
}));
this._stateManager = stateManager ?? null;
// Subscribe to evaluators' external invalidation sources, including those
// nested inside composites, so a change there triggers a re-evaluation
// (this is not necessary for most conditions since they are purely based on
// ConditionState, but there are exceptions, e.g. screen).
this._conditions.forEach(({ evaluator }) =>
(evaluator.externalSources ?? []).forEach((source) =>
this._unsubscribeCallbacks.push(source.subscribe(() => this._evaluate())),
),
);
// Do an initial condition evaluation, but without calling listeners.
this._evaluate({ callListeners: false });
this._stateManager?.addListener(this._stateManagerHandler);
}
public destroy(): void {
this._stateManager?.removeListener(this._stateManagerHandler);
this._listeners.forEach((l) => this.removeListener(l));
this._unsubscribeCallbacks.forEach((unsubscribe) => unsubscribe());
this._unsubscribeCallbacks = [];
this._conditions = [];
}
public addListener(listener: ConditionsListener): void {
if (!this._listeners.includes(listener)) {
this._listeners.push(listener);
}
}
public removeListener(listener: ConditionsListener): void {
this._listeners = this._listeners.filter((l) => l !== listener);
}
public getEvaluation(): ConditionsEvaluationResult {
return this._evaluation;
}
private _stateManagerHandler = (stateChange: ConditionStateChange): void => {
this._evaluate({ stateChange });
};
private _evaluate(options?: {
stateChange?: ConditionStateChange;
callListeners?: boolean;
}): void {
const state = options?.stateChange?.new ?? this._stateManager?.getState();
let result = true;
for (const { config, evaluator } of this._conditions) {
if (!isEnabled(this._templateRenderer, config.enabled, state)) {
continue;
}
if (!evaluator.evaluate(state, options?.stateChange?.old).result) {
result = false;
break;
}
}
const evaluation: ConditionsEvaluationResult = { result };
if (result !== this._evaluation.result) {
this._evaluation = evaluation;
if (options?.callListeners ?? true) {
this._listeners.forEach((listener) =>
listener(this._evaluation, options?.stateChange),
);
}
}
}
}
@@ -0,0 +1,16 @@
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { CompositeConditionEvaluator } from './composite';
export class AndConditionEvaluator extends CompositeConditionEvaluator {
public evaluate(
newState?: ConditionState,
oldState?: ConditionState,
): ConditionsEvaluationResult {
for (const child of this._children) {
if (!child.evaluate(newState, oldState).result) {
return { result: false };
}
}
return { result: true };
}
}
@@ -1,16 +1,17 @@
import { CallBase } from '../../../config/schema/condition-trigger/common/call';
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator, ConditionOfType } from './types';
import { ConditionEvaluator } from './types';
export class CallConditionEvaluator implements ConditionEvaluator {
private _condition: ConditionOfType<'call'>;
private _condition: CallBase;
constructor(condition: ConditionOfType<'call'>) {
constructor(condition: CallBase) {
this._condition = condition;
}
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
return {
result: (this._condition.call ?? true) === (newState?.call ?? false),
result: this._condition.call === (newState?.call ?? false),
};
}
}
@@ -0,0 +1,26 @@
import { CameraBase } from '../../../config/schema/condition-trigger/common/camera';
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator } from './types';
export class CameraConditionEvaluator implements ConditionEvaluator {
private _condition: CameraBase;
constructor(condition: CameraBase) {
this._condition = condition;
}
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
const camera = newState?.camera;
const cameras = this._condition.cameras;
if (cameras === undefined) {
// Omitted: a camera is selected.
return { result: !!camera };
}
if (cameras.length === 0) {
// `[]`: no camera is selected.
return { result: !camera };
}
// A list: the selected camera is one of these.
return { result: !!camera && cameras.includes(camera) };
}
}
@@ -1,9 +1,10 @@
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator, ConditionEvaluatorSubscriptionCallback } from './types';
import { ConditionEvaluator, ExternalInvalidationSource } from './types';
/**
* Base class for the `or`/`and`/`not` composites: each holds child evaluators
* and forwards subscription/teardown to them. Subclasses provide `evaluate`.
* and unions their external invalidation sources. Subclasses provide
* `evaluate`.
*/
export abstract class CompositeConditionEvaluator implements ConditionEvaluator {
protected _children: ConditionEvaluator[];
@@ -17,11 +18,7 @@ export abstract class CompositeConditionEvaluator implements ConditionEvaluator
oldState?: ConditionState,
): ConditionsEvaluationResult;
public subscribe(onChange: ConditionEvaluatorSubscriptionCallback): void {
this._children.forEach((child) => child.subscribe?.(onChange));
}
public destroy(): void {
this._children.forEach((child) => child.destroy?.());
public get externalSources(): ExternalInvalidationSource[] {
return this._children.flatMap((child) => child.externalSources ?? []);
}
}
@@ -1,10 +1,11 @@
import { DisplayModeBase } from '../../../config/schema/condition-trigger/common/display-mode';
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator, ConditionOfType } from './types';
import { ConditionEvaluator } from './types';
export class DisplayModeConditionEvaluator implements ConditionEvaluator {
private _condition: ConditionOfType<'display_mode'>;
private _condition: DisplayModeBase;
constructor(condition: ConditionOfType<'display_mode'>) {
constructor(condition: DisplayModeBase) {
this._condition = condition;
}
@@ -1,10 +1,11 @@
import { ExpandBase } from '../../../config/schema/condition-trigger/common/expand';
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator, ConditionOfType } from './types';
import { ConditionEvaluator } from './types';
export class ExpandConditionEvaluator implements ConditionEvaluator {
private _condition: ConditionOfType<'expand'>;
private _condition: ExpandBase;
constructor(condition: ConditionOfType<'expand'>) {
constructor(condition: ExpandBase) {
this._condition = condition;
}
@@ -1,10 +1,11 @@
import { FullscreenBase } from '../../../config/schema/condition-trigger/common/fullscreen';
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator, ConditionOfType } from './types';
import { ConditionEvaluator } from './types';
export class FullscreenConditionEvaluator implements ConditionEvaluator {
private _condition: ConditionOfType<'fullscreen'>;
private _condition: FullscreenBase;
constructor(condition: ConditionOfType<'fullscreen'>) {
constructor(condition: FullscreenBase) {
this._condition = condition;
}
@@ -1,10 +1,11 @@
import { InteractionBase } from '../../../config/schema/condition-trigger/common/interaction';
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator, ConditionOfType } from './types';
import { ConditionEvaluator } from './types';
export class InteractionConditionEvaluator implements ConditionEvaluator {
private _condition: ConditionOfType<'interaction'>;
private _condition: InteractionBase;
constructor(condition: ConditionOfType<'interaction'>) {
constructor(condition: InteractionBase) {
this._condition = condition;
}
@@ -0,0 +1,8 @@
// Whether a rendered template result counts as true for a CONDITION. HA's
// `condition.py` `async_template` does `value.lower() == "true"`, so only
// `true` (case-insensitive) passes -- `yes`/`on`/`1` do NOT (unlike a trigger).
// The card's renderer returns native types, so the boolean a comparison
// template (e.g. `{{ a == b }}`) produces stringifies to "true" and also
// passes.
export const isTemplateTrue = (value: unknown): boolean =>
String(value).toLowerCase() === 'true';
@@ -0,0 +1,30 @@
import { KeyBase } from '../../../config/schema/condition-trigger/common/key';
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator } from './types';
export class KeyConditionEvaluator implements ConditionEvaluator {
private _condition: KeyBase;
constructor(condition: KeyBase) {
this._condition = condition;
}
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
const condition = this._condition;
// The condition schema requires `key`; the undefined check only guards the
// shared base type, on which it is declared optional.
if (condition.key === undefined || !newState?.keys?.[condition.key]) {
return { result: false };
}
const pressed = newState.keys[condition.key];
return {
result:
(condition.state ?? 'down') === pressed.state &&
(condition.ctrl === undefined || condition.ctrl === !!pressed.ctrl) &&
(condition.alt === undefined || condition.alt === !!pressed.alt) &&
(condition.meta === undefined || condition.meta === !!pressed.meta) &&
(condition.shift === undefined || condition.shift === !!pressed.shift),
};
}
}
@@ -1,10 +1,11 @@
import { MediaLoadedBase } from '../../../config/schema/condition-trigger/common/media-loaded';
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator, ConditionOfType } from './types';
import { ConditionEvaluator } from './types';
export class MediaLoadedConditionEvaluator implements ConditionEvaluator {
private _condition: ConditionOfType<'media_loaded'>;
private _condition: MediaLoadedBase;
constructor(condition: ConditionOfType<'media_loaded'>) {
constructor(condition: MediaLoadedBase) {
this._condition = condition;
}
@@ -1,10 +1,11 @@
import { MicrophoneBase } from '../../../config/schema/condition-trigger/common/microphone';
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator, ConditionOfType } from './types';
import { ConditionEvaluator } from './types';
export class MicrophoneConditionEvaluator implements ConditionEvaluator {
private _condition: ConditionOfType<'microphone'>;
private _condition: MicrophoneBase;
constructor(condition: ConditionOfType<'microphone'>) {
constructor(condition: MicrophoneBase) {
this._condition = condition;
}
@@ -2,14 +2,16 @@ import { ConditionsEvaluationResult, ConditionState } from '../types';
import { CompositeConditionEvaluator } from './composite';
export class NotConditionEvaluator extends CompositeConditionEvaluator {
// "Not" is an inverted `or` (NOR). There is no trigger data for "not
// triggering".
// "Not" is an inverted `or` (NOR): true when no child matches.
public evaluate(
newState?: ConditionState,
oldState?: ConditionState,
): ConditionsEvaluationResult {
return {
result: !this._children.some((child) => child.evaluate(newState, oldState).result),
};
for (const child of this._children) {
if (child.evaluate(newState, oldState).result) {
return { result: false };
}
}
return { result: true };
}
}
@@ -0,0 +1,36 @@
import { arrayify } from '../../../utils/basic';
import { matchesNumericState } from '../../common/numeric-state';
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './types';
export class NumericStateConditionEvaluator implements ConditionEvaluator {
private _condition: ConditionOfType<'numeric_state'>;
private _context: EvaluatorContext;
constructor(condition: ConditionOfType<'numeric_state'>, context: EvaluatorContext) {
this._condition = condition;
this._context = context;
}
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
const condition = this._condition;
// `entity` is canonical; `entity_id` is the accepted automation-dialect alias.
// Either may be a list; with multiple entities all must match (HA's `match: all`).
const entityIDs = arrayify(condition.entity ?? condition.entity_id);
if (!entityIDs.length || !newState) {
return { result: false };
}
return {
result: entityIDs.every((entityID) =>
matchesNumericState(
entityID,
newState,
condition,
this._context.templateRenderer,
),
),
};
}
}
@@ -7,9 +7,8 @@ export class OrConditionEvaluator extends CompositeConditionEvaluator {
oldState?: ConditionState,
): ConditionsEvaluationResult {
for (const child of this._children) {
const evaluation = child.evaluate(newState, oldState);
if (evaluation.result) {
return evaluation;
if (child.evaluate(newState, oldState).result) {
return { result: true };
}
}
return { result: false };
@@ -0,0 +1,22 @@
import { ScreenBase } from '../../../config/schema/condition-trigger/common/screen';
import { MediaQueryWatcher } from '../../common/media-query-watcher';
import { ConditionsEvaluationResult } from '../types';
import { ConditionEvaluator, ExternalInvalidationSource } from './types';
export class ScreenConditionEvaluator implements ConditionEvaluator {
private _watcher: MediaQueryWatcher | null;
constructor(condition: ScreenBase) {
this._watcher = condition.media_query
? new MediaQueryWatcher(condition.media_query)
: null;
}
public evaluate(): ConditionsEvaluationResult {
return { result: this._watcher?.matches() ?? false };
}
public get externalSources(): ExternalInvalidationSource[] {
return this._watcher ? [this._watcher] : [];
}
}
@@ -0,0 +1,104 @@
import { arrayify } from '../../../utils/basic';
import { renderTimePeriodToSeconds } from '../../common/time-period';
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './types';
// Resolve each expected value that names an entity present in `hass` to that
// entity's current state, accepting either the literal or the resolved value
// (i.e. HA's Lovelace state condition will resolve "state: input_boolean.foo"
// to "state: on" when input_boolean.foo is on).
const resolveExpectedStates = (
values: string | string[],
state?: ConditionState,
): string[] =>
// Cannot use `arrayify` as an empty-string expected value is a real value.
(Array.isArray(values) ? values : [values]).flatMap((value) => {
const resolved = state?.hass?.states?.[value]?.state;
return resolved !== undefined ? [value, resolved] : [value];
});
export class StateConditionEvaluator implements ConditionEvaluator {
private _condition: ConditionOfType<'state'>;
private _context: EvaluatorContext;
constructor(condition: ConditionOfType<'state'>, context: EvaluatorContext) {
this._condition = condition;
this._context = context;
}
public evaluate(
newState?: ConditionState,
oldState?: ConditionState,
): ConditionsEvaluationResult {
const condition = this._condition;
// `entity` is canonical; `entity_id` is the accepted automation-dialect alias.
// Either may be a list; with multiple entities all must match (HA's `match: all`).
const entityIDs = arrayify(condition.entity ?? condition.entity_id);
if (!entityIDs.length) {
return { result: false };
}
// The compared value is the attribute when `attribute` is set, else the state.
const readValue = (entityID: string, state?: ConditionState): string | null => {
const stateObj = state?.hass?.states?.[entityID];
if (!stateObj) {
return null;
}
if (condition.attribute) {
const value = stateObj.attributes?.[condition.attribute];
return value === undefined || value === null ? null : String(value);
}
return stateObj.state;
};
const matchesEntity = (entityID: string): boolean => {
const fromValue = readValue(entityID, oldState);
const toValue = readValue(entityID, newState);
let result: boolean;
if (condition.state === undefined && condition.state_not === undefined) {
// With neither `state` nor `state_not`, match any change of value.
result = toValue !== fromValue;
} else if (toValue === null) {
// A missing entity or attribute cannot match; an empty-string state is
// a real value, handled in the comparison below.
result = false;
} else {
result =
(condition.state === undefined ||
resolveExpectedStates(condition.state, newState).includes(toValue)) &&
(condition.state_not === undefined ||
!resolveExpectedStates(condition.state_not, newState).includes(toValue));
}
// `for`: the match must have been held for at least the given duration.
// Evaluated against `last_changed` at evaluation time (correct for the
// point-in-time / ongoing-condition use).
if (result && condition.for !== undefined) {
const forSeconds = renderTimePeriodToSeconds(
this._context.templateRenderer,
condition.for,
newState,
);
const lastChanged = newState?.hass?.states?.[entityID]?.last_changed;
if (forSeconds === null || !lastChanged) {
result = false;
} else {
const heldSeconds =
(new Date().getTime() - new Date(lastChanged).getTime()) / 1000;
result = heldSeconds >= forSeconds;
}
}
return result;
};
// `match: any` requires one entity to match; `all` (the default) requires all.
const result =
condition.match === 'any'
? entityIDs.some(matchesEntity)
: entityIDs.every(matchesEntity);
return { result };
}
}
@@ -1,4 +1,5 @@
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { isTemplateTrue } from './is-template-true';
import { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './types';
export class TemplateConditionEvaluator implements ConditionEvaluator {
@@ -14,11 +15,13 @@ export class TemplateConditionEvaluator implements ConditionEvaluator {
return {
result:
!!newState?.hass &&
this._context.templateRenderer.renderRecursively(
newState.hass,
this._condition.value_template,
{ conditionState: newState },
) === true,
isTemplateTrue(
this._context.templateRenderer.renderRecursively(
newState.hass,
this._condition.value_template,
{ conditionState: newState },
),
),
};
}
}
@@ -0,0 +1,30 @@
import { TriggeredBase } from '../../../config/schema/condition-trigger/common/triggered';
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator } from './types';
export class TriggeredConditionEvaluator implements ConditionEvaluator {
private _condition: TriggeredBase;
constructor(condition: TriggeredBase) {
this._condition = condition;
}
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
const active = newState?.triggered;
const cameraIDs = this._condition.triggered;
const count = active?.size ?? 0;
let result: boolean;
if (cameraIDs === undefined) {
// Omitted: any camera is triggered.
result = count > 0;
} else if (cameraIDs.length === 0) {
// `[]`: no camera is triggered.
result = count === 0;
} else {
// A list: one of the named cameras is among those triggered.
result = !!active && cameraIDs.some((cameraID) => active.has(cameraID));
}
return { result };
}
}
@@ -0,0 +1,37 @@
import { TemplateRenderer } from '../../../card-controller/templates';
import { Condition } from '../../../config/schema/condition-trigger/conditions/types';
import { ConditionsEvaluationResult, ConditionState } from '../types';
export type ExternalInvalidationUnsubscribeCallback = () => void;
// A source of change outside the card's `ConditionState` that can invalidate a
// condition's result (currently only `screen`, via `matchMedia`). A condition
// declares its sources so a reactive consumer knows what to watch; a pull
// consumer ignores them.
export interface ExternalInvalidationSource {
subscribe(callback: () => void): ExternalInvalidationUnsubscribeCallback;
}
/**
* A single condition, constructed once with its configuration and evaluated
* repeatedly against incoming state.
*/
export interface ConditionEvaluator {
evaluate(
newState?: ConditionState,
oldState?: ConditionState,
): ConditionsEvaluationResult;
// Sources of change outside `ConditionState` that can invalidate this
// condition's result (currently only `screen`, via `matchMedia`). A reactive
// consumer subscribes to them to know when to re-evaluate; a pull consumer
// ignores them.
externalSources?: ExternalInvalidationSource[];
}
export interface EvaluatorContext {
templateRenderer: TemplateRenderer;
}
// The condition union member(s) carrying a given discriminator literal.
export type ConditionOfType<T extends string> = Extract<Condition, { condition?: T }>;
@@ -1,5 +1,5 @@
import { isBeingCasted } from '../../utils/casting';
import { isCompanionApp } from '../../utils/companion';
import { isBeingCasted } from '../../../utils/casting';
import { isCompanionApp } from '../../../utils/companion';
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator, ConditionOfType } from './types';
@@ -11,7 +11,8 @@ export class UserConditionEvaluator implements ConditionEvaluator {
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
return {
result:
!!newState?.hass?.user && this._condition.users.includes(newState.hass.user.id),
!!newState?.hass?.user &&
!!this._condition.users?.includes(newState.hass.user.id),
};
}
}
@@ -0,0 +1,20 @@
import { ViewBase } from '../../../config/schema/condition-trigger/common/view';
import { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator } from './types';
export class ViewConditionEvaluator implements ConditionEvaluator {
private _condition: ViewBase;
constructor(condition: ViewBase) {
this._condition = condition;
}
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
const view = newState?.view;
return {
// The condition schema requires `views`; the optional access only guards
// the shared base type, on which it is declared optional.
result: !!view && !!this._condition.views?.includes(view),
};
}
}

Some files were not shown because too many files have changed in this diff Show More