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
+2 -2
View File
@@ -54,6 +54,6 @@ render and can operate in `image` or `folders` views depending on configuration.
#### Common configuration blocks #### Common configuration blocks
| Option | Description | | Option | Description |
| ------------------------------ | --------------------- | | -------------------------------------- | --------------------- |
| [`actions`](actions/README.md) | Configure actions. | | [`actions`](actions/README.md) | Configure actions. |
| [`conditions`](conditions.md) | Configure conditions. | | [`conditions`](conditions-triggers.md) | Configure conditions. |
+1 -1
View File
@@ -3,7 +3,7 @@
- [`actions`](actions/README.md) - [`actions`](actions/README.md)
- [`automations`](automations.md) - [`automations`](automations.md)
- [`cameras`](cameras/README.md) - [`cameras`](cameras/README.md)
- [`conditions`](conditions.md) - [`conditions` / `triggers`](conditions-triggers.md)
- [`dimensions`](dimensions.md) - [`dimensions`](dimensions.md)
- [`elements`](elements/README.md) - [`elements`](elements/README.md)
- [`folders`](folders.md) - [`folders`](folders.md)
+1 -1
View File
@@ -5,7 +5,7 @@
- [Stock Actions](./stock/README.md) - [Stock Actions](./stock/README.md)
- [`automations`](../automations.md) - [`automations`](../automations.md)
- [`cameras`](../cameras/README.md) - [`cameras`](../cameras/README.md)
- [`conditions`](../conditions.md) - [`conditions` / `triggers`](../conditions-triggers.md)
- [`dimensions`](../dimensions.md) - [`dimensions`](../dimensions.md)
- [`elements`](../elements/README.md) - [`elements`](../elements/README.md)
- [`folders`](../folders.md) - [`folders`](../folders.md)
@@ -5,7 +5,7 @@
- [Stock Actions](../stock/README.md) - [Stock Actions](../stock/README.md)
- [`automations`](../../automations.md) - [`automations`](../../automations.md)
- [`cameras`](../../cameras/README.md) - [`cameras`](../../cameras/README.md)
- [`conditions`](../../conditions.md) - [`conditions` / `triggers`](../../conditions-triggers.md)
- [`dimensions`](../../dimensions.md) - [`dimensions`](../../dimensions.md)
- [`elements`](../../elements/README.md) - [`elements`](../../elements/README.md)
- [`folders`](../../folders.md) - [`folders`](../../folders.md)
@@ -1,5 +1,33 @@
# Stock Actions # Stock Actions
## `if` / `then` / `else`
Run one sequence of actions or another depending on a set of
[conditions](../../conditions-triggers.md). This action has no `action:` key: it
is identified by the presence of an `if` key, exactly as in [Home Assistant
script syntax](https://www.home-assistant.io/docs/scripts/#if-then). The `then`
sequence runs when all `if` conditions hold; the optional `else` sequence runs
otherwise.
```yaml
if:
- condition: state
entity_id: input_boolean.notify_enabled
state: 'on'
then:
- action: fire-dom-event
advanced_camera_card_action: live_substream_on
else:
- action: fire-dom-event
advanced_camera_card_action: live_substream_off
```
| Parameter | Description |
| --------- | ----------------------------------------------------------------- |
| `if` | A list of [conditions](../../conditions-triggers.md) to evaluate. |
| `then` | A list of actions to run when all `if` conditions hold. |
| `else` | An optional list of actions to run when the `if` conditions fail. |
## `more-info` ## `more-info`
Open the "more-info" dialog for an entity. See [Home Assistant actions documentation](https://www.home-assistant.io/dashboards/actions/). Open the "more-info" dialog for an entity. See [Home Assistant actions documentation](https://www.home-assistant.io/dashboards/actions/).
@@ -117,4 +145,21 @@ elements:
tap_action: tap_action:
action: fire-dom-event action: fire-dom-event
key: value key: value
- type: icon
icon: mdi:numeric-8-box
title: If / then / else action
style:
left: 200px
top: 400px
tap_action:
if:
- condition: state
entity_id: light.office_main_lights
state: 'on'
then:
- action: fire-dom-event
advanced_camera_card_action: live_substream_on
else:
- action: fire-dom-event
advanced_camera_card_action: live_substream_off
``` ```
+1 -1
View File
@@ -5,7 +5,7 @@
- [Stock Actions](README.md) - [Stock Actions](README.md)
- [`automations`](../../automations.md) - [`automations`](../../automations.md)
- [`cameras`](../../cameras/README.md) - [`cameras`](../../cameras/README.md)
- [`conditions`](../../conditions.md) - [`conditions` / `triggers`](../../conditions-triggers.md)
- [`dimensions`](../../dimensions.md) - [`dimensions`](../../dimensions.md)
- [`elements`](../../elements/README.md) - [`elements`](../../elements/README.md)
- [`folders`](../../folders.md) - [`folders`](../../folders.md)
+25 -13
View File
@@ -1,25 +1,36 @@
# `automations` # `automations`
Automatically take [actions](actions/README.md) based on [conditions](conditions.md) being met. Automatically run [actions](actions/README.md) in response to
[triggers](conditions-triggers.md), optionally gated by [conditions](conditions-triggers.md).
> [!TIP] > [!TIP]
> To change configuration conditionally use [overrides](overrides.md). > To change configuration conditionally, use [overrides](overrides.md) instead.
An automation has three parts, mirroring a Home Assistant automation:
- **`triggers:`** are the momentary occurrences that start the automation
(required). Multiple triggers are independent: any one firing runs the
automation (an implicit "or").
- **`conditions:`** are ongoing predicates checked the instant a trigger fires;
they must _all_ hold for `actions` to run (optional).
- **`actions:`** are what runs when a trigger fires and the conditions hold
(required).
```yaml ```yaml
automations: automations:
- conditions: - triggers:
- [trigger]
conditions:
- [condition] - [condition]
actions: actions:
- [action] - [action]
actions_not:
- [action]
``` ```
| Option | Default | Description | | Option | Default | Description |
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ | | ------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `conditions` | | A list of [conditions](conditions.md) that must evaluate to `true` in order to trigger the automation. | | `triggers` | | A list of [triggers](conditions-triggers.md) that initiate the automation. At least one is required. |
| `actions` | | An optional list of [actions](actions/README.md) that will be run when the [conditions](conditions.md) evaluate `true`. | | `conditions` | | An optional list of [conditions](conditions-triggers.md) that must _all_ evaluate `true` at the instant a trigger fires for `actions` to run. |
| `actions_not` | | An optional list of [actions](actions/README.md) that will be run when the [conditions](conditions.md) evaluate `false`. | | `actions` | | A list of [actions](actions/README.md) run when a trigger fires and the conditions hold (or no conditions are configured). |
# Fully expanded reference # Fully expanded reference
@@ -27,13 +38,14 @@ automations:
```yaml ```yaml
automations: automations:
- conditions: - triggers:
- trigger: state
entity_id: binary_sensor.front_door
to: 'on'
conditions:
- condition: fullscreen - condition: fullscreen
fullscreen: true fullscreen: true
actions: actions:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_on advanced_camera_card_action: substream_on
actions_not:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_off
``` ```
+9 -2
View File
@@ -17,7 +17,7 @@ cameras_global:
``` ```
| Option | Default | Description | | Option | Default | Description |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `always_error_if_entity_unavailable` | `false` | When `true` and when `camera_entity` is specified, attempting to live stream this camera will always error out if the entity state is `unavailable`, even if the `live_provider` does not actually need the `camera_entity`. | | `always_error_if_entity_unavailable` | `false` | When `true` and when `camera_entity` is specified, attempting to live stream this camera will always error out if the entity state is `unavailable`, even if the `live_provider` does not actually need the `camera_entity`. |
| `camera_entity` | | The Home Assistant camera entity. Used by most live providers for live stream data, and to auto-detect other camera metadata (e.g. Frigate camera name, camera title/icon). | | `camera_entity` | | The Home Assistant camera entity. Used by most live providers for live stream data, and to auto-detect other camera metadata (e.g. Frigate camera name, camera title/icon). |
| `capabilities` | | Allows selective disabling of camera capabilities. See [`capabilities`](#capabilities). | | `capabilities` | | Allows selective disabling of camera capabilities. See [`capabilities`](#capabilities). |
@@ -27,7 +27,7 @@ cameras_global:
| `engine` | `auto` | The camera engine to use. If `auto` the card will attempt to choose the correct engine from the specified options. See [Engine](engine.md). | | `engine` | `auto` | The camera engine to use. If `auto` the card will attempt to choose the correct engine from the specified options. See [Engine](engine.md). |
| `frigate` | | Options for Frigate cameras. See [Frigate camera engine configuration](engine.md?id=frigate). | | `frigate` | | Options for Frigate cameras. See [Frigate camera engine configuration](engine.md?id=frigate). |
| `icon` | Autodetected from `camera_entity` if that is specified. | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. | | `icon` | Autodetected from `camera_entity` if that is specified. | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. |
| `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). If none of these are set, the camera has no `id` and cannot be referenced by name in conditions or actions. | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. This `id` may be used in [conditions](../conditions.md), dependencies or custom [actions](../actions/README.md) to refer to a given camera unambiguously. | | `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). If none of these are set, the camera has no `id` and cannot be referenced by name in conditions or actions. | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. This `id` may be used in [conditions](../conditions-triggers.md), dependencies or custom [actions](../actions/README.md) to refer to a given camera unambiguously. |
| `live_provider` | `auto` | The choice of live stream provider. See [Live Provider](live-provider.md). | | `live_provider` | `auto` | The choice of live stream provider. See [Live Provider](live-provider.md). |
| `media` | | Controls the default media configuration (e.g. thumbnails) for this camera. See [`media`](#media). | | `media` | | Controls the default media configuration (e.g. thumbnails) for this camera. See [`media`](#media). |
| `proxy` | | Controls whether/how content is proxied via [hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) (must be installed separately). See [`proxy`](#proxy). | | `proxy` | | Controls whether/how content is proxied via [hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) (must be installed separately). See [`proxy`](#proxy). |
@@ -374,6 +374,13 @@ to activate an action (e.g. view a camera in live, reset the card to the default
view). See [`view.triggers`](../view.md?id=triggers) to control what happens when a view). See [`view.triggers`](../view.md?id=triggers) to control what happens when a
camera is triggered. camera is triggered.
> [!TIP]
> A camera's `triggers` are not the same as an
> [automation's](../automations.md) [`triggers`](../conditions-triggers.md). Camera
> triggers take action on per-camera events such as motion; automation triggers
> _initiate [automations](../automations.md)_. They share only the word
> "trigger".
```yaml ```yaml
cameras: cameras:
- camera_entity: camera.office - camera_entity: camera.office
+1 -1
View File
@@ -5,7 +5,7 @@
- [`cameras`](README.md) - [`cameras`](README.md)
- [`live_provider`](live-provider.md) - [`live_provider`](live-provider.md)
- [`engine`](engine.md) - [`engine`](engine.md)
- [`conditions`](../conditions.md) - [`conditions` / `triggers`](../conditions-triggers.md)
- [`dimensions`](../dimensions.md) - [`dimensions`](../dimensions.md)
- [`folders`](../folders.md) - [`folders`](../folders.md)
- [`elements`](../elements/README.md) - [`elements`](../elements/README.md)
+793
View File
@@ -0,0 +1,793 @@
# Conditions & triggers
Conditions and triggers are designed to mirror Home Assistant's own
[conditions](https://www.home-assistant.io/docs/scripts/conditions/) and
[triggers](https://www.home-assistant.io/docs/automation/trigger/) as closely as
possible: for the standard types Home Assistant's own documentation applies, and
you can copy conditions and triggers straight out of an existing Home Assistant
automation. The card adds a number of card-specific types, and is a little more
permissive in places; any differences are noted per type below.
A **trigger** is what wakes an [automation](automations.md) up. The moment a
trigger fires, the card checks any **conditions** you have set, and if they all
pass it runs the [actions](actions/README.md). The two therefore play different
roles:
- A **trigger** is a _momentary_ occurrence. Used only under `triggers:`, and
only in automations.
- A **condition** is an _ongoing_ predicate, true or false at a point in time.
Besides gating automations (checked the instant a trigger fires), conditions
also drive [overrides](overrides.md) and [picture elements](elements/README.md).
The same type can usually be used either way, but the meaning differs: as a
**condition** it asks _"is this true right now?"_; as a **trigger** it fires
_"when this becomes true"_. A few types are restricted to one role (`config` is
trigger-only; the composites and `user` / `user_agent` are condition-only), as
noted at the top of each type below.
For the card-state types (`camera`, `view`, `fullscreen`, `expand`, `call`,
`display_mode`, `media_loaded`, `microphone`, `interaction`, `triggered`) a
trigger's value is **optional**: give it a value to fire only when the state
changes _to_ that value, or **omit it to fire on any change**. (The stock `state`
trigger behaves the same way when `from`/`to` are omitted). As a condition the
value keeps its usual per-type meaning, as described below.
```yaml
# A trigger initiates an automation; conditions are then checked.
triggers:
- [trigger_1]
conditions:
- [condition_1]
```
> [!TIP]
> Automation `triggers` are not the same as a camera's
> [`triggers`](cameras/README.md?id=triggers). Automation triggers _initiate
> [automations](automations.md)_; camera triggers take action on per-camera
> events such as motion. They share only the word "trigger".
## Universal fields <!-- {docsify-ignore} -->
Every condition and trigger accepts an optional `enabled` field, mirroring Home
Assistant.
| Parameter | Description |
| --------- | ----------------------------------------------------------------------------------------------------------- |
| `enabled` | `true` (the default) keeps it active; `false`, or a [template](templates.md) that renders falsey, skips it. |
> [!NOTE]
> An `enabled` template can turn a condition or trigger on or off at runtime:
> point it at an `input_boolean` (or any live value) and the change takes effect
> immediately, because the card re-evaluates `enabled` every time the condition
> is evaluated or trigger fires. This is an intentional extension: Home Assistant
> fixes `enabled` once when the automation loads, for both conditions and
> triggers.
Home Assistant's `id`, `alias` and `variables` keys are also _accepted_ on any
condition or trigger (so automations pasted from Home Assistant will validate),
but will have no effect.
## `and`
_Condition only._
Evaluates to `true` if _all_ embedded conditions evaluate to `true`. At least one condition is required.
```yaml
conditions:
- condition: and
# [...]
```
Or, in shorthand form:
```yaml
conditions:
- and:
# [...]
```
| Parameter | Description |
| ------------ | -------------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `and`. |
| `conditions` | A list of other conditions _all_ of which must evaluate `true` in order for this condition to evaluate `true`. |
## `call`
Matches whether a [two-way audio](../usage/2-way-audio.md) call is in progress.
As a **condition**, true while the call state matches; as a **trigger**, fires
when it becomes a match (e.g. `call: true` fires when a call starts).
```yaml
# As a condition:
conditions:
- condition: call
call: true
# As a trigger:
triggers:
- trigger: call
call: true
```
| Parameter | Description |
| ----------------------- | ---------------------------------------------------------------------------------------------- |
| `condition` / `trigger` | Must be `call`. |
| `call` | If `true` or `false`, matches when a two-way audio call is or is not in progress respectively. |
## `camera`
Matches the selected camera. As a **condition**, true while the selection
matches; as a **trigger**, fires when the selection changes to a match. Does not
match other cameras (whether visible or not).
```yaml
# As a condition:
conditions:
- condition: camera
cameras: [front_door]
# As a trigger:
triggers:
- trigger: camera
cameras: [front_door]
```
| Parameter | Description |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `condition` / `trigger` | Must be `camera`. |
| `cameras` | An optional list of camera IDs. **A list** matches one of those cameras; **omitted** matches the presence of any selected camera (as a trigger: any selected camera change); **`[]`** matches when no camera is selected. See the camera [id](cameras/README.md) parameter. |
## `config`
_Trigger only._
Fires when the card configuration changes (e.g. on startup, or when [overrides](./overrides.md) are applied).
```yaml
triggers:
- trigger: config
# [...]
```
| Parameter | Description |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `trigger` | Must be `config`. |
| `paths` | An optional list of configuration paths (e.g. `menu.style`). If provided, fires only when _any_ of those paths changes; otherwise fires on any configuration change. |
## `display_mode`
Matches the card display mode (`single` or `grid`). As a **condition**, true
while in that mode; as a **trigger**, fires when the display mode changes to it.
See the display settings for [`live`](live.md?id=display) or
[`media_viewer`](media-viewer.md?id=display).
```yaml
# As a condition:
conditions:
- condition: display_mode
display_mode: single
# As a trigger:
triggers:
- trigger: display_mode
display_mode: single
```
| Parameter | Description |
| ----------------------- | --------------------------- |
| `condition` / `trigger` | Must be `display_mode`. |
| `display_mode` | Must be `single` or `grid`. |
## `expand`
Matches whether the card is in "expanded" mode (in a dialog/popup). As a
**condition**, true while the mode matches; as a **trigger**, fires when it
becomes a match.
```yaml
# As a condition:
conditions:
- condition: expand
expand: true
# As a trigger:
triggers:
- trigger: expand
expand: true
```
| Parameter | Description |
| ----------------------- | ----------------------------------------------------------------------------------------------------------- |
| `condition` / `trigger` | Must be `expand`. |
| `expand` | If `true` or `false`, matches when the card is or is not in expanded mode (in a dialog/popup) respectively. |
## `fullscreen`
Matches whether the card (or media within it) is in fullscreen mode. As a
**condition**, true while the mode matches; as a **trigger**, fires when it
becomes a match.
> [!WARNING]
> When fullscreen is entered via a video player's built-in controls (rather than
> the card's own fullscreen [action](actions/custom/README.md) or menu button),
> the browser fullscreens the video element itself rather than the card. Any
> automation action that replaces that video element (e.g. switching substreams)
> will immediately exit fullscreen. A partial workaround may be to use the
> card's fullscreen action instead. See [Fullscreen with HD substream
> switching](../examples.md?id=fullscreen-with-hd-substream-switching) for an
> approach that combines substream switching with the card's fullscreen.
```yaml
# As a condition:
conditions:
- condition: fullscreen
fullscreen: true
# As a trigger, on entering fullscreen:
triggers:
- trigger: fullscreen
fullscreen: true
# As a trigger, on any fullscreen change:
triggers:
- trigger: fullscreen
```
| Parameter | Description |
| ----------------------- | ----------------------------------------------------------------------------------------- |
| `condition` / `trigger` | Must be `fullscreen`. |
| `fullscreen` | If `true` or `false`, matches when the card is or is not in fullscreen mode respectively. |
## `initialized`
Matches whether the card has finished initializing. As a **condition**, true
once the card is initialized; as a **trigger**, fires when the card initializes
(useful for running an [automation](./automations.md) on card start).
```yaml
# As a condition:
conditions:
- condition: initialized
# As a trigger:
triggers:
- trigger: initialized
```
| Parameter | Description |
| ----------------------- | ---------------------- |
| `condition` / `trigger` | Must be `initialized`. |
## `interaction`
Matches whether the card has recently been interacted with. As a **condition**,
true while the interaction state matches; as a **trigger**, fires when it becomes
a match.
```yaml
# As a condition:
conditions:
- condition: interaction
interaction: true
# As a trigger:
triggers:
- trigger: interaction
interaction: true
```
| Parameter | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `condition` / `trigger` | Must be `interaction`. |
| `interaction` | If `true` or `false`, matches when the card has or has not had human interaction within `view.interaction_seconds` elapsed seconds respectively. |
## `key`
Matches a keyboard key. As a **condition**, true while the key matches the given
state; as a **trigger**, fires on the matching key event.
```yaml
# As a condition:
conditions:
- condition: key
key: ArrowLeft
# As a trigger:
triggers:
- trigger: key
key: ArrowLeft
```
| Parameter | Default | Description |
| ----------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `condition` / `trigger` | - | Must be `key`. |
| `alt` | `false` | An optional value to match whether the `alt` key is being held. |
| `ctrl` | `false` | An optional value to match whether the `ctrl` key is being held. |
| `key` | | Any [keyboard key value](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values), e.g. `ArrowLeft`. |
| `meta` | `false` | An optional value to match whether the `meta` key is being held. |
| `shift` | `false` | An optional value to match whether the `shift` key is being held. |
| `state` | `down` | An optional value to match the state of the key. Must be one of `down` or `up`. |
## `media_loaded`
Matches whether the selected live or media stream has loaded. As a **condition**,
true while the load state matches; as a **trigger**, fires when it becomes a
match.
```yaml
# As a condition:
conditions:
- condition: media_loaded
media_loaded: true
# As a trigger:
triggers:
- trigger: media_loaded
media_loaded: true
```
| Parameter | Description |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `condition` / `trigger` | Must be `media_loaded`. |
| `media_loaded` | If `true` or `false`, matches when there is or is not media load**ED** (not load**ING**) in the card (e.g. a clip, snapshot or live view). This may be used to hide controls during media loading or when a message (not media) is being displayed. |
> [!NOTE]
> Toggling a substream on or off does not cause this condition to transition.
> Substream is treated as a playback-layer detail of the same logical camera, so
> the condition remains satisfied while any stream of the camera continues to
> render.
## `microphone`
Matches the microphone state. As a **condition**, true while the mute state
matches; as a **trigger**, fires when it becomes a match.
```yaml
# As a condition:
conditions:
- condition: microphone
muted: true
# As a trigger:
triggers:
- trigger: microphone
muted: true
```
| Parameter | Description |
| ----------------------- | ----------------------------------------------------------------------------------- |
| `condition` / `trigger` | Must be `microphone`. |
| `muted` | If `true` or `false`, matches when the microphone is muted or unmuted respectively. |
## `not`
_Condition only._
Evaluates to `true` if every embedded condition is `false`. At least one
condition is required.
> [!IMPORTANT] > `not` is a **NOR** operation, not a **NAND**. If _any_ sub-condition is `true`,
> the `not` condition evaluates to `false` -- even if other sub-conditions are
> `false`. To pass, _all_ sub-conditions must be `false`. This behavior matches
> the [Home Assistant equivalent](https://www.home-assistant.io/docs/scripts/conditions/#not-condition).
```yaml
conditions:
- condition: not
# [...]
```
Or, in shorthand form:
```yaml
conditions:
- not:
# [...]
```
| Parameter | Description |
| ------------ | --------------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `not`. |
| `conditions` | A list of other conditions _none_ of which must evaluate `true` in order for this condition to evaluate `true`. |
## `numeric_state`
Matches a numeric Home Assistant value (an entity's state or attribute, or a
template). As a **condition**, true while the value is within range; as a
**trigger**, fires when the value crosses into range. At least one of `above` /
`below` is required.
```yaml
# As a condition:
conditions:
- condition: numeric_state
entity: sensor.office_temperature
above: 10
below: 20
# As a trigger:
triggers:
- trigger: numeric_state
entity_id: sensor.office_temperature
above: 10
below: 20
```
| Parameter | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------- |
| `condition` / `trigger` | Must be `numeric_state`. |
| `entity` / `entity_id` | The entity (or list of entities) to read. |
| `above` | Match when the value is above this: a number, or an entity ID whose state supplies the threshold. |
| `below` | Match when the value is below this: a number, or an entity ID whose state supplies the threshold. |
| `value_template` | A template whose rendered numeric value is compared instead of the entity's state. |
| `attribute` | Compare this attribute instead of the entity's state. |
| `for` | _Trigger only._ A duration (`hh:mm:ss` or a template) the value must stay in range before firing. |
See the [Home Assistant numeric_state condition](https://www.home-assistant.io/docs/scripts/conditions/#numeric-state-condition) and [numeric_state trigger](https://www.home-assistant.io/docs/automation/trigger/#numeric-state-trigger).
## `or`
_Condition only._
Evaluates to `true` if _any_ embedded condition evaluates to `true`. At least one condition is required.
```yaml
conditions:
- condition: or
# [...]
```
Or, in shorthand form:
```yaml
conditions:
- or:
# [...]
```
| Parameter | Description |
| ------------ | -------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `or`. |
| `conditions` | A list of conditions _any_ of which must evaluate `true` in order for this condition to evaluate `true`. |
## `screen`
Matches a CSS [media query](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Media_queries/Using).
As a **condition**, true while the query matches; as a **trigger**, fires when
the match changes (e.g. on a change of orientation or viewport size).
```yaml
# As a condition:
conditions:
- condition: screen
media_query: '(orientation: landscape)'
# As a trigger:
triggers:
- trigger: screen
media_query: '(orientation: landscape)'
```
| Parameter | Description |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `condition` / `trigger` | Must be `screen`. |
| `media_query` | Any valid [media query](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Media_queries/Using) string. Media queries must start and end with parentheses. This may be used to alter card configuration based on device/media properties (e.g. viewport width, orientation). Please note that `width` and `height` refer to the entire viewport not just the card. |
See the [screen conditions examples](../examples.md?id=screen-conditions).
## `state`
Matches a Home Assistant entity's state. Unlike most types, the **condition** and
**trigger** forms take different fields: a condition compares the _current_ value
(requiring `state` or `state_not`), while a trigger matches the _transition_
(`from` / `to`, both optional).
Both forms accept `entity` (or its `entity_id` alias) as a single entity or a
list.
### As a condition
```yaml
conditions:
- condition: state
entity: binary_sensor.door
state: 'on'
```
| Parameter | Description |
| ---------------------- | --------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `state`. |
| `entity` / `entity_id` | The entity (or list of entities) to check. |
| `state` | A state, or list of states, the entity must match. |
| `state_not` | A state, or list of states, the entity must not match. |
| `match` | With a list of entities: `all` (the default) requires every entity to match, `any` requires at least one. |
| `for` | A duration (`hh:mm:ss` or a template) the match must have held. |
| `attribute` | Compare this attribute instead of the entity's state. |
See the [Home Assistant state condition](https://www.home-assistant.io/docs/scripts/conditions/#state-condition).
### As a trigger
```yaml
triggers:
- trigger: state
entity_id: binary_sensor.door
to: 'on'
```
| Parameter | Description |
| ---------------------- | ---------------------------------------------------------------------------- |
| `trigger` | Must be `state`. |
| `entity` / `entity_id` | The entity (or list of entities) to watch. |
| `from` / `not_from` | Match (or exclude) the prior state. A single value, a list, or `null` (any). |
| `to` / `not_to` | Match (or exclude) the new state. A single value, a list, or `null` (any). |
| `for` | A duration (`hh:mm:ss` or a template) the new state must hold before firing. |
| `attribute` | Watch this attribute instead of the entity's state. |
See the [Home Assistant state trigger](https://www.home-assistant.io/docs/automation/trigger/#state-trigger).
## `template`
Matches a Home Assistant template. As a **condition**, true while the template
renders `true`; as a **trigger**, fires when it changes from non-true to true.
```yaml
# As a condition:
conditions:
- condition: template
value_template: "{{ states('switch.office') == 'on' }}"
# As a trigger:
triggers:
- trigger: template
value_template: "{{ states('switch.office') == 'on' }}"
```
| Parameter | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------ |
| `condition` / `trigger` | Must be `template`. |
| `value_template` | The Home Assistant template to evaluate, e.g. `{{ states('switch.office') == 'on' }}`. |
| `for` | _Trigger only._ A duration (`hh:mm:ss` or a template) the template must stay true before firing. |
See the [Home Assistant template condition](https://www.home-assistant.io/docs/scripts/conditions/#template-condition) and [template trigger](https://www.home-assistant.io/docs/automation/trigger/#template-trigger).
> [!NOTE]
> In order to match native Home Assistant behavior, condition and trigger
> truthiness differ: a **condition** passes only when the template renders
> `true` (case-insensitive), whereas a **trigger** also accepts broader truthy
> values (`1`, `yes`, `on`, `enable`).
> [!NOTE]
> A **trigger** is re-evaluated when card or Home Assistant state changes, not on
> a timer, so a template that depends only on time (e.g. `{{ now().hour == 8 }}`)
> will not fire on its own.
> [!TIP]
> The Advanced Camera Card uses
> [ha-nunjucks](https://github.com/Nerwyn/ha-nunjucks) to process templates.
> Consult its documentation for the wide variety of different template values
> supported.
## `triggered`
Matches the set of cameras currently [triggered](cameras/README.md?id=triggers).
As a **condition**, true while the set matches; as a **trigger**, fires when it
becomes a match.
```yaml
# As a condition:
conditions:
- condition: triggered
triggered: [camera.office]
# As a trigger:
triggers:
- trigger: triggered
triggered: [camera.office]
```
| Parameter | Description |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `condition` / `trigger` | Must be `triggered`. |
| `triggered` | An optional list of camera IDs. Matches when one of them is triggered. **Omit** to match while _any_ camera is triggered; use an empty list `[]` to match while _none_ is. |
## `user`
_Condition only._
Matches the logged-in Home Assistant user. See the [Home Assistant user condition](https://www.home-assistant.io/dashboards/conditional/#user).
```yaml
conditions:
- condition: user
users:
- 581fca7fdc014b8b894519cc531f9a04
```
| Parameter | Description |
| ----------- | ------------------------------------------- |
| `condition` | Must be `user`. |
| `users` | A list of Home Assistant user IDs to match. |
## `user_agent`
_Condition only._
Matches the [User-Agent](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/User-Agent).
```yaml
conditions:
- condition: user_agent
# [...]
```
| Parameter | Description |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `user_agent`. |
| `user_agent` | Exactly matches a user-agent, e.g. `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36` |
| `user_agent_re` | Matches a user-agent based on a regular expression, e.g. `Chrome/`. |
| `casting` | If `true` matches if the card is being cast to a Chromecast / TV device, if `false` matches if the card is _NOT_ being cast. |
| `companion` | If `true` matches if the user-agent is the Home Assistant companion app, if `false` matches if the user-agent is _NOT_ the Home Assistant companion app. |
At least one of these parameters is required. When multiple are specified they
must all match for the condition to match.
See the [user-agent overrides example](../examples.md?id=disable-ptz-controls-in-the-home-assistant-companion-app).
## `view`
Matches the selected view. As a **condition**, true while a matching view is
selected; as a **trigger**, fires when the selected view changes to a matching
one.
```yaml
# As a condition:
conditions:
- condition: view
views: [live]
# As a trigger:
triggers:
- trigger: view
views: [live]
```
| Parameter | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `condition` / `trigger` | Must be `view`. |
| `views` | A list of [views](view.md?id=supported-views) to match (e.g. `clips`). **Required** as a condition; optional as a trigger (omit to fire on any view change). |
> [!IMPORTANT]
> Internally, views associated with the media viewer (e.g. `clip`, `snapshot`,
> `review`, `recording`) are translated to the `media` view after the relevant
> media is fetched. When naming views in a condition or trigger, you may need to
> refer to the `media` view.
## Unsupported Home Assistant conditions and triggers
Several Home Assistant condition types are **not** currently supported: `time`,
`zone`, `sun`, `location`, `device`, and `condition: trigger` (matching on the
`id` of the trigger that fired).
On the trigger side, only the stock `state`, `numeric_state` and `template`
platforms are supported, alongside the card-specific triggers listed above.
Other Home Assistant trigger platforms -- including `event`, `time`,
`time_pattern`, `sun`, `zone`, `calendar`, `webhook`, `tag`, `device` and
`mqtt` -- are **not** supported.
If you need any of these, please [open an
issue](https://github.com/dermotduffy/advanced-camera-card/issues).
## Fully expanded reference
[](common/expanded-warning.md ':include')
### Conditions
```yaml
conditions:
- and:
- condition: camera
cameras: [front_door]
- condition: view
views: [live]
- condition: call
call: true
- condition: camera
cameras:
- camera.office
- condition: display_mode
display_mode: single
- condition: expand
expand: true
- condition: fullscreen
fullscreen: true
- condition: initialized
- condition: interaction
interaction: true
- condition: key
alt: false
ctrl: false
key: F
meta: false
shift: false
state: down
- condition: media_loaded
media_loaded: true
- condition: microphone
muted: true
- not:
- condition: fullscreen
fullscreen: true
- condition: numeric_state
entity: sensor.office_temperature
above: 10
below: 20
- or:
- condition: camera
cameras: [front_door]
- condition: view
views: [live]
- condition: screen
media_query: '(orientation: landscape)'
- condition: state
entity: climate.office
state: heat
state_not: 'off'
- condition: template
value_template: "{{ is_state('switch.office', 'on') }}"
- condition: triggered
triggered:
- camera.office
- condition: user
users:
- 581fca7fdc014b8b894519cc531f9a04
- condition: user_agent
user_agent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
user_agent_re: 'Chrome/'
casting: true
companion: true
- condition: view
views:
- live
```
### Triggers
```yaml
triggers:
- trigger: call
call: true
- trigger: camera
cameras:
- camera.office
- trigger: config
paths:
- 'menu.style'
- trigger: display_mode
display_mode: single
- trigger: expand
expand: true
- trigger: fullscreen
fullscreen: true
- trigger: initialized
- trigger: interaction
interaction: true
- trigger: key
alt: false
ctrl: false
key: F
meta: false
shift: false
state: down
- trigger: media_loaded
media_loaded: true
- trigger: microphone
muted: true
- trigger: numeric_state
entity_id: sensor.office_temperature
above: 10
below: 20
for: '00:00:05'
- trigger: screen
media_query: '(orientation: landscape)'
- trigger: state
entity_id: climate.office
from: 'off'
to: heat
for: '00:00:05'
- trigger: template
value_template: "{{ is_state('switch.office', 'on') }}"
for: '00:00:05'
- trigger: triggered
triggered:
- camera.office
- trigger: view
views:
- live
```
-457
View File
@@ -1,457 +0,0 @@
# `conditions`
`conditions` is not a top-level configuration block, but can be used as part of
multiple other blocks.
Conditions are used to conditionally take action (in `automations`), to apply
certain configurations (in `overrides`) or to display "picture elements" (in
`elements`) depending on runtime evaluation.
```yaml
[used as part of other configuration]
conditions:
- [condition_1]
- [condition_2]
```
## `and`
Evaluates to `true` if _all_ embedded conditions evaluate to `true`. At least one condition is required.
```yaml
conditions:
- condition: and
# [...]
```
| Parameter | Description |
| ------------ | -------------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `and`. |
| `conditions` | A list of other conditions _all_ of which must evaluate `true` in order for this condition to evaluate `true`. |
## `call`
Matches based on whether a [two-way audio](../usage/2-way-audio.md) call is in progress.
```yaml
conditions:
- condition: call
# [...]
```
| Parameter | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `call`. |
| `call` | If `true` (the default) or `false`, the condition is satisfied when a two-way audio call is or is not in progress respectively. |
## `camera`
Matches based on the selected camera. Does not match other cameras (whether
visible or not).
```yaml
conditions:
- condition: camera
# [...]
```
| Parameter | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `camera`. |
| `cameras` | An optional list of camera IDs in which this condition is satisfied. If not specified, any camera change will satisy the condition. See the camera [id](cameras/README.md) parameter. |
## `config`
Matches when card configuration changes (e.g. on startup, or when [Configuration Overrides](./overrides.md) are applied).
```yaml
conditions:
- condition: config
# [...]
```
| Parameter | Description |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `config`. |
| `paths` | An optional array of configuration paths (e.g. `menu.style`). If provided condition matches if _ANY_ of the provided configuration paths has changed. |
## `display_mode`
Matches when card display mode changes (e.g. `single` or `grid` mode). See the display settings for [`live`](live.md?id=display) or [`media_viewer`](media-viewer.md?id=display).
```yaml
conditions:
- condition: display_mode
# [...]
```
| Parameter | Description |
| -------------- | --------------------------- |
| `condition` | Must be `display_mode`. |
| `display_mode` | Must be `single` or `grid`. |
## `expand`
Matches based on whether the card is in "expanded" mode.
```yaml
conditions:
- condition: expand
# [...]
```
| Parameter | Description |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `expand`. |
| `expand` | If `true` the condition is satisfied if the card is in expanded mode (in a dialog/popup). If `false` the condition is satisfied if the card is **NOT** in expanded mode (in a dialog/popup). |
## `fullscreen`
Matches based on whether the card (or media within it) is in fullscreen mode.
> [!WARNING]
> When fullscreen is entered via a video player's built-in controls (rather than
> the card's own fullscreen [action](actions/custom/README.md) or menu button),
> the browser fullscreens the video element itself rather than the card. Any
> automation action that replaces that video element (e.g. switching substreams)
> will immediately exit fullscreen. A partial workaround may be to use the
> card's fullscreen action instead. See [Fullscreen with HD substream
> switching](../examples.md?id=fullscreen-with-hd-substream-switching) for an
> approach that combines substream switching with the card's fullscreen.
```yaml
conditions:
- condition: fullscreen
# [...]
```
| Parameter | Description |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `fullscreen`. |
| `fullscreen` | If `true` the condition is satisfied if the card is in fullscreen mode. If `false` the condition is satisfied if the card is **NOT** in fullscreen mode. |
## `initialized`
Matches when the card is first initialized.
```yaml
conditions:
- condition: initialized
```
| Parameter | Description |
| ----------- | ---------------------- |
| `condition` | Must be `initialized`. |
> [!NOTE]
> This is exclusively useful for running [automations](./automations.md) on card start.
## `interaction`
Matches based on whether the card has been interacted with.
```yaml
conditions:
- condition: interaction
# [...]
```
| Parameter | Description |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `interaction`. |
| `interaction` | If `true` the condition is satisfied if the card has had human interaction within `view.interaction_seconds` elapsed seconds. If `false` the condition is satisfied if the card has **NOT** had human interaction in that time. |
## `key`
Matches based on key state.
```yaml
conditions:
- condition: key
# [...]
```
| Parameter | Default | Description |
| ----------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `condition` | - | Must be `key`. |
| `alt` | `false` | An optional value to match whether the `alt` key is being held. |
| `ctrl` | `false` | An optional value to match whether the `ctrl` key is being held. |
| `key` | | Any [keyboard key value](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values), e.g. `ArrowLeft`. |
| `meta` | `false` | An optional value to match whether the `meta` key is being held. |
| `shift` | `false` | An optional value to match whether the `shift` key is being held. |
| `state` | `down` | An optional value to match the state of the key. Must be one of `down` or `up`. |
## `media_loaded`
Matches based on whether the selected live or media stream has loaded.
```yaml
conditions:
- condition: media_loaded
# [...]
```
| Parameter | Description |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `media_loaded`. |
| `media_loaded` | If `true` the condition is satisfied if there is media load**ED** (not load**ING**) in the card (e.g. a clip, snapshot or live view). This may be used to hide controls during media loading or when a message (not media) is being displayed. |
> [!NOTE]
> Toggling a substream on or off does not cause this condition to transition.
> Substream is treated as a playback-layer detail of the same logical camera, so
> the condition remains satisfied while any stream of the camera continues to
> render.
## `microphone`
Matches based on microphone state.
```yaml
conditions:
- condition: microphone
# [...]
```
| Parameter | Description |
| ----------- | ------------------------------------------------------------------------------------------------------ |
| `condition` | Must be `microphone`. |
| `muted` | If `true` or `false`, the condition is satisfied when the microphone is muted or unmuted respectively. |
## `not`
Evaluates to `true` if every embedded condition is `false`. At least one
condition is required.
> [!IMPORTANT] > `not` is a **NOR** operation, not a **NAND**. If _any_ sub-condition is `true`, the `not` condition evaluates to `false` — even if other sub-conditions are `false`. To pass, _all_ sub-conditions must be `false`. This behavior matches the [Home Assistant equivalent](https://www.home-assistant.io/docs/scripts/conditions/#not-condition).
```yaml
conditions:
- condition: not
# [...]
```
| Parameter | Description |
| ------------ | --------------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `not`. |
| `conditions` | A list of other conditions _none_ of which must evaluate `true` in order for this condition to evaluate `true`. |
## `numeric_state`
Matches based on numeric Home Assistant state.
```yaml
conditions:
- condition: numeric_state
# [...]
```
See [Home Assistant conditions documentation](https://www.home-assistant.io/dashboards/conditional/#numeric-state).
## `or`
Evaluates to `true` if _any_ embedded condition evaluates to `true`. At least one condition is required.
```yaml
conditions:
- condition: or
# [...]
```
| Parameter | Description |
| ------------ | -------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `or`. |
| `conditions` | A list of conditions _any_ of which must evaluate `true` in order for this condition to evaluate `true`. |
## `screen`
Matches based on [media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Media_queries/Using).
```yaml
conditions:
- condition: screen
# [...]
```
| Parameter | Description |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `screen`. |
| `media_query` | Any valid [media query](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Media_queries/Using) string. Media queries must start and end with parentheses. This may be used to alter card configuration based on device/media properties (e.g. viewport width, orientation). Please note that `width` and `height` refer to the entire viewport not just the card. |
See the [screen conditions examples](../examples.md?id=screen-conditions).
## `state`
Matches based on Home Assistant state.
```yaml
conditions:
- condition: state
# [...]
```
| Parameter | Description |
| ----------- | ------------------------------------------------------------------------------------------------------ |
| `condition` | Must be `state`. |
| `entity` | The entity to check the state of. |
| `state` | A single entity state, or list of entity states, against which the entity state is compared. |
| `state_not` | A single entity state, or list of entity states, against which the entity state is inversely compared. |
> [!NOTE]
> If multiple state conditions are used together with neither `state` nor
> `state_not` specified, this effectively means the state for multiple entities
> needs to _change_ simultaneously. This is unlikely to happen in reality, and
> almost certainly not useful / reliable as a condition.
See [Home Assistant conditions documentation](https://www.home-assistant.io/dashboards/conditional/#state).
## `template`
Matches based on a template.
```yaml
conditions:
- condition: template
# [...]
```
| Parameter | Description |
| ---------------- | ---------------------------------------------------------------------------------- |
| `condition` | Must be `template`. |
| `value_template` | The Home Assistant template to check, e.g. `{{ states('switch.office') == 'on' }}` |
See [Home Assistant conditions documentation](https://www.home-assistant.io/docs/scripts/conditions/#template-condition).
> [!TIP]
> The Advanced Camera Card uses
> [ha-nunjucks](https://github.com/Nerwyn/ha-nunjucks) to process templates.
> Consult its documentation for the wide variety of different template values
> supported.
## `triggered`
Matches based on whether the selected camera has been triggered.
```yaml
conditions:
- condition: triggered
# [...]
```
| Parameter | Description |
| ----------- | ------------------------------------------------------------------------------------------------- |
| `condition` | Must be `triggered`. |
| `triggered` | A list of camera IDs which, if [triggered](cameras/README.md?id=triggers), satisfy the condition. |
## `user`
Matches based on the Home Assistant user that is logged in. See [Home Assistant conditions documentation](https://www.home-assistant.io/dashboards/conditional/#user).
```yaml
conditions:
- condition: user
# [...]
```
## `user_agent`
Matches based on the [User-Agent](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/User-Agent).
```yaml
conditions:
- condition: user_agent
# [...]
```
| Parameter | Description |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `condition` | Must be `user_agent`. |
| `user_agent` | Exactly matches a user-agent, e.g. `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36` |
| `user_agent_re` | Matches a user-agent based on a regular expression, e.g. `Chrome/`. |
| `casting` | If `true` matches if the card is being cast to a Chromecast / TV device, if `false` matches if the card is _NOT_ being cast. |
| `companion` | If `true` matches if the user-agent is the Home Assistant companion app, if `false` matches if the user-agent is _NOT_ the Home Assistant companion app. |
When multiple parameters are specified they must all match for the condition to
match.
See the [user-agent overrides example](../examples.md?id=disable-ptz-controls-in-the-home-assistant-companion-app).
## `view`
Matches based on the selected view.
```yaml
conditions:
- condition: view
# [...]
```
| Parameter | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `condition` | Must be `view`. |
| `views` | An optional list of [views](view.md?id=supported-views) in which this condition is satified (e.g. `clips`). If not specified, any view change will satisy the condition. |
> [!IMPORTANT]
> Internally, views associated with the media viewer (e.g. `clip`, `snapshot`,
> `review` `recording`) are translated to the `media` view after the relevant
> media is fetched. When including views as part of a
> [condition](conditions.md), you may need to refer to the `media` view.
## Fully expanded reference
[](common/expanded-warning.md ':include')
```yaml
conditions:
- condition: call
call: true
- condition: camera
cameras:
- camera.office
- condition: config
paths:
- 'menu.style'
- condition: display_mode
display_mode: single
- condition: expand
expand: true
- condition: fullscreen
fullscreen: true
- condition: initialized
- condition: interaction
interaction: true
- condition: key
alt: false
ctrl: false
key: F
meta: false
shift: false
state: down
- condition: media_loaded
media_loaded: true
- condition: microphone
muted: true
- condition: numeric_state
entity: sensor.office_temperature
above: 10
below: 20
- condition: screen
media_query: '(orientation: landscape)'
- condition: state
entity: climate.office
state: heat
state_not: off
- condition: triggered
triggered:
- camera.office
- condition: user
users:
- 581fca7fdc014b8b894519cc531f9a04
- condition: user_agent
user_agent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
user_agent_re: 'Chrome/'
casting: true
companion: true
- condition: view
views:
- live
```
+1 -1
View File
@@ -3,7 +3,7 @@
- [`actions`](../actions/README.md) - [`actions`](../actions/README.md)
- [`automations`](../automations.md) - [`automations`](../automations.md)
- [`cameras`](../cameras/README.md) - [`cameras`](../cameras/README.md)
- [`conditions`](../conditions.md) - [`conditions` / `triggers`](../conditions-triggers.md)
- [`dimensions`](../dimensions.md) - [`dimensions`](../dimensions.md)
- [`elements`](README.md) - [`elements`](README.md)
- [Custom Elements](./custom/README.md) - [Custom Elements](./custom/README.md)
+3 -3
View File
@@ -2,7 +2,7 @@
## `conditional` ## `conditional`
Restrict a set of elements to only render when the card is matches a set of [conditions](../../conditions.md). This is analogous to the stock [`conditional`](../stock/README.md?id=conditional) element except supporting a rich set of Advanced Camera Card [conditions](../../conditions.md). Restrict a set of elements to only render when the card is matches a set of [conditions](../../conditions-triggers.md). This is analogous to the stock [`conditional`](../stock/README.md?id=conditional) element except supporting a rich set of Advanced Camera Card [conditions](../../conditions-triggers.md).
```yaml ```yaml
elements: elements:
@@ -13,9 +13,9 @@ elements:
Parameters for the `custom:advanced-camera-card-conditional` element: Parameters for the `custom:advanced-camera-card-conditional` element:
| Parameter | Description | | Parameter | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------------- | | ------------ | ------------------------------------------------------------------------------------------------------------------------- |
| `type` | Must be `custom:advanced-camera-card-conditional`. | | `type` | Must be `custom:advanced-camera-card-conditional`. |
| `conditions` | A list of [conditions](../../conditions.md) that must evaluate to true in order for the elements to be rendered. | | `conditions` | A list of [conditions](../../conditions-triggers.md) that must evaluate to true in order for the elements to be rendered. |
| `elements` | The elements to render. Can be any supported element. | | `elements` | The elements to render. Can be any supported element. |
See the [conditional elements example](../../../examples.md?id=conditional-elements). See the [conditional elements example](../../../examples.md?id=conditional-elements).
@@ -3,7 +3,7 @@
- [`actions`](../README.md) - [`actions`](../README.md)
- [`automations`](../../automations.md) - [`automations`](../../automations.md)
- [`cameras`](../../cameras/README.md) - [`cameras`](../../cameras/README.md)
- [`conditions`](../../conditions.md) - [`conditions` / `triggers`](../../conditions-triggers.md)
- [`dimensions`](../../dimensions.md) - [`dimensions`](../../dimensions.md)
- [`elements`](../../elements/README.md) - [`elements`](../../elements/README.md)
- [Custom Actions](README.md) - [Custom Actions](README.md)
@@ -3,7 +3,7 @@
- [`actions`](../README.md) - [`actions`](../README.md)
- [`automations`](../../automations.md) - [`automations`](../../automations.md)
- [`cameras`](../../cameras/README.md) - [`cameras`](../../cameras/README.md)
- [`conditions`](../../conditions.md) - [`conditions` / `triggers`](../../conditions-triggers.md)
- [`dimensions`](../../dimensions.md) - [`dimensions`](../../dimensions.md)
- [`elements`](../../elements/README.md) - [`elements`](../../elements/README.md)
- [Custom Actions](../custom/README.md) - [Custom Actions](../custom/README.md)
+3 -3
View File
@@ -1,6 +1,6 @@
# `overrides` # `overrides`
The card configuration may [conditionally](conditions.md) be overridden (e.g. to The card configuration may [conditionally](conditions-triggers.md) be overridden (e.g. to
hide the menu in fullscreen mode). hide the menu in fullscreen mode).
```yaml ```yaml
@@ -17,8 +17,8 @@ The top-level `overrides` configuration block expects a list, with each list
item containing `conditions` and at least one of `merge`, `delete` or `set` specified. item containing `conditions` and at least one of `merge`, `delete` or `set` specified.
| Option | Default | Description | | Option | Default | Description |
| ------------ | ------- | ------------------------------------------------------------------------------------------------------------ | | ------------ | ------- | --------------------------------------------------------------------------------------------------------------------- |
| `conditions` | | A list of [conditions](conditions.md) that must evaluate to `true` in order for the overrides to be applied. | | `conditions` | | A list of [conditions](conditions-triggers.md) that must evaluate to `true` in order for the overrides to be applied. |
| `delete` | | An array of configuration paths to delete. See [`delete`](#delete). | | `delete` | | An array of configuration paths to delete. See [`delete`](#delete). |
| `merge` | | A dictionary of configuration paths to merge. See [`merge`](#merge). | | `merge` | | A dictionary of configuration paths to merge. See [`merge`](#merge). |
| `set` | | A dictionary of configuration paths to set. See [`set`](#set). | | `set` | | A dictionary of configuration paths to set. See [`set`](#set). |
+33 -27
View File
@@ -7,6 +7,12 @@ Advanced Camera Card data, to be accessible. Templates may be used in:
- [Actions / Automations](./actions/README.md) - [Actions / Automations](./actions/README.md)
- [Folder Media Matchers](./folders.md?id=matchers) - [Folder Media Matchers](./folders.md?id=matchers)
> [!NOTE]
> Templates substitute into action _values_ (e.g. a `camera` or `message`), not
> into the action _type_. The type discriminator (`action` and
> `advanced_camera_card_action`) must be a literal (as in Home Assistant
> itself).
## Stock Templates ## Stock Templates
The Advanced Camera Card uses The Advanced Camera Card uses
@@ -19,13 +25,13 @@ accesses Home Assistant state.
## Custom Templates ## Custom Templates
Custom template values must be proceeded by `advanced_camera_card` (or `acc` for Custom template values must be prefixed with `acc`.
short).
| Template | Replaced with | | Template | Replaced with |
| -------- | ------------------------------------------------- | | -------- | ------------------------------------------------- |
| `camera` | The currently selected camera. | | `camera` | The currently selected camera. |
| `view` | The current [view](./view.md?id=supported-views). | | `view` | The current [view](./view.md?id=supported-views). |
| `config` | The current card configuration. |
See [an example](../examples.md?id=accessing-advanced-camera-card-state) that See [an example](../examples.md?id=accessing-advanced-camera-card-state) that
accesses Advanced Camera Card state. accesses Advanced Camera Card state.
@@ -35,8 +41,7 @@ accesses Advanced Camera Card state.
If templates are used for [Folder Media Matching](./folders.md?id=matchers) an If templates are used for [Folder Media Matching](./folders.md?id=matchers) an
additional `media` variable is available with these properties: additional `media` variable is available with these properties:
Media template values must be proceeded by `advanced_camera_card.media` (or Media template values must be prefixed with `acc.media`.
`acc.media` for short).
| Template | Replaced with | | Template | Replaced with |
| ----------- | --------------------------------------------------------------------------------- | | ----------- | --------------------------------------------------------------------------------- |
@@ -45,34 +50,35 @@ Media template values must be proceeded by `advanced_camera_card.media` (or
### Triggers ### Triggers
If the action is called by an [Advanced Camera Card When an action runs from an [automation](./automations.md), a top-level
Automation](./automations.md), additional data is available representing the `trigger` variable describes what fired it (as in native Home Assistant
current and prior state of whatever triggered the action. actions), including the state before and after the change. Its fields depend on
the kind of trigger.
Trigger template values must be proceeded by `advanced_camera_card.trigger` (or The stock `state` and `numeric_state` [triggers](./conditions-triggers.md) carry
`acc.trigger` for short). Home-Assistant-faithful entity data (a subset of Home Assistant's own [trigger
data](https://www.home-assistant.io/docs/automation/templating/#available-trigger-data):
the card does not currently surface `id`, `idx`, `for`, `attribute`, `above` /
`below` or `alias`, so [request](https://github.com/dermotduffy/advanced-camera-card/issues)
if you need more). The `template` trigger has no entity, so it carries only
`trigger.platform` (`template`).
| Template | Replaced with | | Template | Replaced with |
| -------------- | ------------------------------------------------------------------------------------------------ | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `camera.to` | For [camera conditions](./conditions.md?id=camera), the currently selected camera. | | `trigger.platform` | The trigger platform (`state` or `numeric_state`). |
| `camera.from` | For [camera conditions](./conditions.md?id=camera), the previously selected camera. | | `trigger.entity_id` | The entity that triggered (also available as `trigger.entity`). |
| `view.to` | For [view conditions](./conditions.md?id=view), the currently selected view. | | `trigger.from_state` | The full Home Assistant [state object](https://www.home-assistant.io/docs/configuration/state_object/) before the change, e.g. `trigger.from_state.state`. |
| `view.from` | For [view conditions](./conditions.md?id=view), the previously selected view. | | `trigger.to_state` | The full Home Assistant state object after the change, e.g. `trigger.to_state.state` or `trigger.to_state.attributes.<name>`. |
| `state.entity` | For [state conditions](./conditions.md?id=state), the entity state that triggered the condition. |
| `state.to` | For [state conditions](./conditions.md?id=state), the current state of the entity. |
| `state.from` | For [state conditions](./conditions.md?id=state), the previous state of the entity. |
> [!NOTE] The card-specific [triggers](./conditions-triggers.md) (e.g. `camera`, `view`, `config`)
> If an action is triggered with multiple [state carry the card state before and after the change:
> conditions](./conditions.md?id=state), only data from the last listed state
> condition is available.
> [!NOTE] | Template | Replaced with |
> If you use an [`or`](./conditions.md?id=or) condition, only the trigger data | ------------------ | ------------------------------------------------------------------------------------------------------ |
> for the first matching trigger will be included. | `trigger.platform` | `acc` for card-specific triggers. |
| `trigger.type` | The card trigger kind (e.g. `camera`, `view`, `config`). |
Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if | `trigger.from_acc` | The card state before the change, with `camera`, `view` and `config` (e.g. `trigger.from_acc.camera`). |
you need data from additional conditions. | `trigger.to_acc` | The card state after the change, with `camera`, `view` and `config` (e.g. `trigger.to_acc.camera`). |
See [an example](../examples.md?id=accessing-trigger-state) that accesses See [an example](../examples.md?id=accessing-trigger-state) that accesses
trigger state. trigger state.
+2 -2
View File
@@ -14,7 +14,7 @@ view:
| `dim` | `false` | Whether or not to 'dim' the brightness of the card (by 25%) if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time). | | `dim` | `false` | Whether or not to 'dim' the brightness of the card (by 25%) if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time). |
| `default` | `auto` | The view to show in the card by default. If `auto`, the card will choose `live` when cameras are configured, `folders` when folders are configured, or `image` otherwise (default embedded image). The default camera is the first one listed. See [Supported Views](view.md?id=supported-views). | | `default` | `auto` | The view to show in the card by default. If `auto`, the card will choose `live` when cameras are configured, `folders` when folders are configured, or `image` otherwise (default embedded image). The default camera is the first one listed. See [Supported Views](view.md?id=supported-views). |
| `default_reset` | | The circumstances and behavior that cause the card to reset to the default view. See [`default_reset`](#default_reset). | | `default_reset` | | The circumstances and behavior that cause the card to reset to the default view. See [`default_reset`](#default_reset). |
| `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `default_reset.after_interaction` to reset the view after the interaction is complete. | | `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions-triggers.md?id=interaction) or with `default_reset.after_interaction` to reset the view after the interaction is complete. |
| `issues` | | How the card handles issues and retries. See [`issues`](#issues). | | `issues` | | How the card handles issues and retries. See [`issues`](#issues). |
| `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See [`keyboard_shortcuts`](#keyboard_shortcuts). | | `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See [`keyboard_shortcuts`](#keyboard_shortcuts). |
| `render_entities` | | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/advanced-camera-card/issues/343)). | | `render_entities` | | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/advanced-camera-card/issues/343)). |
@@ -201,7 +201,7 @@ This card supports several different views.
The default view is `auto`. It will select `live` when cameras are configured, `folders` when folders are configured, or `image` otherwise (default embedded image). You can override this with `view.default`. The default view is `auto`. It will select `live` when cameras are configured, `folders` when folders are configured, or `image` otherwise (default embedded image). You can override this with `view.default`.
> [!NOTE] > [!NOTE]
> When using views in a [`view` condition](conditions.md?id=view), the single-item viewer views (`clip`, `snapshot`, `review`, `recording`) are translated internally to `media` once the relevant media is fetched. You may need to match on `media` rather than the original view name in your condition. > When using views in a [`view` condition](conditions-triggers.md?id=view), the single-item viewer views (`clip`, `snapshot`, `review`, `recording`) are translated internally to `media` once the relevant media is fetched. You may need to match on `media` rather than the original view name in your condition.
## Fully expanded reference ## Fully expanded reference
+61 -32
View File
@@ -47,7 +47,7 @@ fullscreen mode.
> When fullscreen is entered via a video player's built-in controls (rather than > When fullscreen is entered via a video player's built-in controls (rather than
> the card's fullscreen menu button), automation actions that replace _that_ video > the card's fullscreen menu button), automation actions that replace _that_ video
> element (e.g. switching substreams from one video to another) will immediately exit fullscreen (as the browser sees the video the user clicked on be destroyed). See the > element (e.g. switching substreams from one video to another) will immediately exit fullscreen (as the browser sees the video the user clicked on be destroyed). See the
> [fullscreen condition](configuration/conditions.md?id=fullscreen) for details. > [fullscreen condition](configuration/conditions-triggers.md?id=fullscreen) for details.
```yaml ```yaml
type: custom:advanced-camera-card type: custom:advanced-camera-card
@@ -72,13 +72,18 @@ cameras:
# Optionally allow PTZ controls on the substream. # Optionally allow PTZ controls on the substream.
- ptz - ptz
automations: automations:
- conditions: # Entering fullscreen: turn the substream on.
- condition: fullscreen - triggers:
- trigger: fullscreen
fullscreen: true fullscreen: true
actions: actions:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_on advanced_camera_card_action: substream_on
actions_not: # Exiting fullscreen: turn it off again.
- triggers:
- trigger: fullscreen
fullscreen: false
actions:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_off advanced_camera_card_action: substream_off
``` ```
@@ -121,8 +126,8 @@ elements:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: fullscreen advanced_camera_card_action: fullscreen
automations: automations:
- conditions: - triggers:
- condition: fullscreen - trigger: fullscreen
fullscreen: false fullscreen: false
actions: actions:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
@@ -134,7 +139,7 @@ automations:
### Responding to key input ### Responding to key input
In addition to a handful of reconfigurable [built-in keyboard shortcuts](./usage/keyboard-shortcuts.md), `automations` can be used to take any action based on any keyboard input. These examples use [`key` conditions](./configuration/conditions.md?id=key) to assess keyboard state before taking action. In addition to a handful of reconfigurable [built-in keyboard shortcuts](./usage/keyboard-shortcuts.md), `automations` can be used to take any action based on any keyboard input. These examples use [`key` conditions](./configuration/conditions-triggers.md?id=key) to assess keyboard state before taking action.
#### Change to `live` temporarily #### Change to `live` temporarily
@@ -142,8 +147,8 @@ In this example, the view will change to `live`, when `Alt+Z` is pressed, and ch
```yaml ```yaml
automations: automations:
- conditions: - triggers:
- condition: key - trigger: key
key: z key: z
alt: true alt: true
actions: actions:
@@ -163,14 +168,22 @@ In this example, the view will change to `live`, when `Alt+Z` is _held_ down, an
```yaml ```yaml
automations: automations:
- conditions: # Held down: switch to live.
- condition: key - triggers:
- trigger: key
key: z key: z
alt: true alt: true
state: down
actions: actions:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: live advanced_camera_card_action: live
actions_not: # Released: switch back to clips.
- triggers:
- trigger: key
key: z
alt: true
state: up
actions:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: clips advanced_camera_card_action: clips
``` ```
@@ -708,15 +721,20 @@ cameras:
# Also allow PTZ controls on the substream. # Also allow PTZ controls on the substream.
- ptz - ptz
automations: automations:
- actions: # On interaction: turn the HD substream on.
- triggers:
- trigger: interaction
interaction: true
actions:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_on advanced_camera_card_action: substream_on
actions_not: # Once the interaction lapses: turn it off again.
- triggers:
- trigger: interaction
interaction: false
actions:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_off advanced_camera_card_action: substream_off
conditions:
- condition: interaction
interaction: true
``` ```
## Media layout ## Media layout
@@ -880,7 +898,7 @@ elements:
## Overriding configuration ## Overriding configuration
You can override card configuration when certain [conditions](configuration/conditions.md) are met. You can override card configuration when certain [conditions](configuration/conditions-triggers.md) are met.
### Change menu position based on HA state ### Change menu position based on HA state
@@ -1165,8 +1183,9 @@ type: custom:advanced-camera-card
cameras: cameras:
- camera_entity: camera.office - camera_entity: camera.office
automations: automations:
- conditions: # Camera triggered: show the alert.
- condition: triggered - triggers:
- trigger: triggered
triggered: triggered:
- camera.office - camera.office
actions: actions:
@@ -1182,7 +1201,11 @@ automations:
expand: true expand: true
exclusive: true exclusive: true
sufficient: true sufficient: true
actions_not: # No longer triggered: reset the status bar.
- triggers:
- trigger: triggered
triggered: []
actions:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: status_bar advanced_camera_card_action: status_bar
status_bar_action: reset status_bar_action: reset
@@ -1368,8 +1391,8 @@ tap_action:
action: perform-action action: perform-action
perform_action: homeassistant.service perform_action: homeassistant.service
data: data:
camera: '{{ advanced_camera_card.camera }}' camera: '{{ acc.camera }}'
view: '{{ advanced_camera_card.view }}' view: '{{ acc.view }}'
``` ```
See [Custom Templates](./configuration/templates.md?id=custom-templates). See [Custom Templates](./configuration/templates.md?id=custom-templates).
@@ -1381,14 +1404,14 @@ and values associated with the triggering are included in the action.
```yaml ```yaml
automations: automations:
- conditions: - triggers:
- condition: camera - trigger: camera
actions: actions:
- action: perform-action - action: perform-action
perform_action: homeassistant.service perform_action: homeassistant.service
data: data:
from_camera: '{{ acc.trigger.camera.from }}' from_camera: '{{ trigger.from_acc.camera }}'
to_camera: '{{ acc.trigger.camera.to }}' to_camera: '{{ trigger.to_acc.camera }}'
``` ```
See [Trigger Templates](./configuration/templates.md?id=triggers). See [Trigger Templates](./configuration/templates.md?id=triggers).
@@ -1685,10 +1708,11 @@ cameras:
debug: debug:
logging: true logging: true
automations: automations:
- conditions: # Door opens: zoom in.
- condition: state - triggers:
entity: binary_sensor.door_contact - trigger: state
state: 'on' entity_id: binary_sensor.door_contact
to: 'on'
actions: actions:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: ptz_digital advanced_camera_card_action: ptz_digital
@@ -1698,7 +1722,12 @@ automations:
pan: pan:
x: 38 x: 38
y: 20 y: 20
actions_not: # Door closes: zoom back out.
- triggers:
- trigger: state
entity_id: binary_sensor.door_contact
to: 'off'
actions:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: ptz_digital advanced_camera_card_action: ptz_digital
target_id: camera.living_room target_id: camera.living_room
+17
View File
@@ -43,6 +43,23 @@ To upgrade:
If the automatic upgrade button is not visible, your configuration may already If the automatic upgrade button is not visible, your configuration may already
be up to date. Try clearing your browser cache and reloading. be up to date. Try clearing your browser cache and reloading.
### Configuration could not be fully upgraded
Automatic configuration upgrades are not perfect. If you see a notification that
your configuration could not be fully upgraded, you will find part of your
configuration set aside -- untouched -- under an `__UPGRADE_FAILURE__` field.
To resolve it:
1. Edit your card's YAML manually and find the `__UPGRADE_FAILURE__` key.
1. Re-create each entry under it in the current format (see the relevant
configuration documentation in the sidebar). An upgrade usually fails this
way when a feature has been retired, or changed enough that the migration
needs human judgement.
1. Once you're happy with the result, delete the `__UPGRADE_FAILURE__` key.
The notification clears once the `__UPGRADE_FAILURE__` key is gone.
### Media does not load ### Media does not load
Media not loading? Permanent "loading circle"? Media not loading? Permanent "loading circle"?
+1 -1
View File
@@ -79,7 +79,7 @@ Calls can also be controlled programmatically with the
[`call_answer`](../configuration/actions/custom/README.md?id=call_answer), and [`call_answer`](../configuration/actions/custom/README.md?id=call_answer), and
[`call_end`](../configuration/actions/custom/README.md?id=call_end) actions -- [`call_end`](../configuration/actions/custom/README.md?id=call_end) actions --
for example, from an [automation](../configuration/automations.md) that fires for example, from an [automation](../configuration/automations.md) that fires
when a doorbell sensor triggers. The [`call` condition](../configuration/conditions.md?id=call) when a doorbell sensor triggers. The [`call` condition](../configuration/conditions-triggers.md?id=call)
can be used to show or hide elements while a call is in progress. can be used to show or hide elements while a call is in progress.
### Call lifecycle ### Call lifecycle
+1 -1
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
import { KeyedSubscriptionManager } from '../../utils/keyed-subscription-manager'; import { KeyedSubscriptionManager } from '../../utils/concurrency/keyed-subscription-manager';
import { import {
FrigateEventChange, FrigateEventChange,
FrigateReviewChange, FrigateReviewChange,
+2 -1
View File
@@ -203,7 +203,8 @@ export class CameraManager {
const engine = engineType const engine = engineType
? engines.get(engineType) ?? ? engines.get(engineType) ??
(await this._engineFactory.createEngine(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(), stateWatcher: this._api.getHASSManager().getStateWatcher(),
eventWatcher: this._api.getHASSManager().getEventWatcher(), eventWatcher: this._api.getHASSManager().getEventWatcher(),
resolvedMediaCache: this._api.getResolvedMediaCache(), resolvedMediaCache: this._api.getResolvedMediaCache(),
+42 -11
View File
@@ -1,5 +1,6 @@
import { ActionContext } from 'action'; import { ActionContext } from 'action';
import { z } from 'zod'; import { z } from 'zod';
import { TriggerData } from '../../condition-trigger/triggers/types.js';
import { import {
ActionConfig, ActionConfig,
Actions, Actions,
@@ -14,7 +15,11 @@ import { allPromises, errorToConsole } from '../../utils/basic.js';
import { TemplateRenderer } from '../templates/index.js'; import { TemplateRenderer } from '../templates/index.js';
import { CardActionsManagerAPI } from '../types.js'; import { CardActionsManagerAPI } from '../types.js';
import { ActionSet } from './actions/set.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; const INTERACTIONS = ['tap', 'double_tap', 'hold', 'start_tap', 'end_tap'] as const;
export type InteractionName = (typeof INTERACTIONS)[number]; export type InteractionName = (typeof INTERACTIONS)[number];
@@ -138,23 +143,31 @@ export class ActionsManager implements ActionsExecutor {
request: ActionsExecutionRequest, request: ActionsExecutionRequest,
renderTemplates = true, renderTemplates = true,
): Promise<void> { ): Promise<void> {
const hass = this._api.getHASSManager().getHASS(); // Lock filtering and the factory both classify on the raw (unrendered)
const renderedAction: ActionConfig | ActionConfig[] = // discriminator (`action` / `advanced_camera_card_action`). A templated
renderTemplates && hass && this._templateRenderer // `advanced_camera_card_action` (permitted only by the loose custom-action
? (this._templateRenderer.renderRecursively(hass, request.actions, { // schema) is left unresolved, matches no action type, and is dropped.
conditionState: this._api.getConditionStateManager().getState(), const allowedActions = this._api.getLockManager().getAllowedActions(request.actions);
triggerData: request?.triggerData,
}) as ActionConfig | ActionConfig[])
: request.actions;
const allowedActions = this._api.getLockManager().getAllowedActions(renderedAction);
if (!allowedActions.length) { if (!allowedActions.length) {
return; 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, { const actionSet = new ActionSet(this._actionContext, allowedActions, {
factoryOptions: {
config: request.config, config: request.config,
cardID: this._api.getConfigManager().getConfig()?.card_id, cardID: this._api.getConfigManager().getConfig()?.card_id,
triggerData: request?.triggerData,
},
actionPrepareCallback,
}); });
this._actionsInFlight.push(actionSet); this._actionsInFlight.push(actionSet);
@@ -168,4 +181,22 @@ export class ActionsManager implements ActionsExecutor {
} }
this._actionsInFlight = this._actionsInFlight.filter((a) => a !== actionSet); 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, AuxillaryActionConfig,
} from '../../../config/schema/actions/types.js'; } from '../../../config/schema/actions/types.js';
import { localize } from '../../../localize/localize.js'; import { localize } from '../../../localize/localize.js';
import { isAdvancedCameraCardCustomAction } from '../../../utils/action'; import { getActionName } from '../../../utils/action';
import { CardActionsAPI } from '../../types'; import { CardActionsAPI } from '../../types';
import { Action, ActionAbortError } from '../types'; import { Action, ActionAbortError, ActionPrepareCallback } from '../types';
export class BaseAction<T extends ActionConfig> implements Action { export class BaseAction<T extends ActionConfig> implements Action {
protected _context: ActionContext; protected _context: ActionContext;
protected _action: T; protected _rawAction: T;
protected _preparedAction: T | null = null;
protected _config?: AuxillaryActionConfig; protected _config?: AuxillaryActionConfig;
constructor(context: ActionContext, action: T, config?: AuxillaryActionConfig) { constructor(context: ActionContext, action: T, config?: AuxillaryActionConfig) {
this._context = context; this._context = context;
this._action = action; this._rawAction = action;
this._config = config; 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 { protected _shouldSeekConfirmation(api: CardActionsAPI): boolean {
const hass = api.getHASSManager().getHASS(); const hass = api.getHASSManager().getHASS();
const action: ActionConfig = this._action; const action: ActionConfig = this._getAction();
return ( return (
(typeof action.confirmation === 'boolean' && action.confirmation) || (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> { public async execute(api: CardActionsAPI): Promise<void> {
if (this._shouldSeekConfirmation(api)) { if (this._shouldSeekConfirmation(api)) {
const action: ActionConfig = this._action; const action: ActionConfig = this._getAction();
const baseAction = action.action;
const actionName = isAdvancedCameraCardCustomAction(action)
? action.advanced_camera_card_action
: baseAction;
const text = const text =
(typeof action.confirmation === 'object' ? action.confirmation.text : null) ?? (typeof action.confirmation === 'object' ? action.confirmation.text : null) ??
`${localize('actions.confirmation')}: ${actionName}`; `${localize('actions.confirmation')}: ${getActionName(action)}`;
if (!confirm(text)) { if (!confirm(text)) {
throw new ActionAbortError(localize('actions.abort')); throw new ActionAbortError(localize('actions.abort'));
} }
@@ -11,7 +11,8 @@ export class CallServiceAction extends AdvancedCameraCardAction<CallServiceActio
return; return;
} }
const [domain, service] = this._action.service.split('.', 2); const action = this._getAction();
await hass.callService(domain, service, this._action.data, this._action.target); 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> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
const action = this._getAction();
await api.getCallManager().start({ await api.getCallManager().start({
cameraID: this._action.camera, cameraID: action.camera,
streamID: this._action.stream, streamID: action.stream,
}); });
} }
} }
@@ -6,10 +6,11 @@ export class CameraSelectAction extends AdvancedCameraCardAction<CameraSelectAct
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
const action = this._getAction();
const selectCameraID = const selectCameraID =
this._action.camera ?? action.camera ??
(this._action.triggered (action.triggered
? api.getTriggersManager().getMostRecentlyTriggeredCameraID() ? api.getCameraTriggersManager().getMostRecentlyTriggeredCameraID()
: null); : null);
const view = api.getViewManager().getView(); const view = api.getViewManager().getView();
const config = api.getConfigManager().getConfig(); const config = api.getConfigManager().getConfig();
@@ -7,6 +7,10 @@ export class CustomAction extends AdvancedCameraCardAction<CustomActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); 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({ await api.getViewManager().setViewByParametersWithNewQuery({
params: { 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> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
switch (this._action.effect_action) { const action = this._getAction();
switch (action.effect_action) {
case 'start': case 'start':
api.getEffectsManager().startEffect(this._action.effect); api.getEffectsManager().startEffect(action.effect);
break; break;
case 'stop': case 'stop':
api.getEffectsManager().stopEffect(this._action.effect); api.getEffectsManager().stopEffect(action.effect);
break; break;
case 'toggle': case 'toggle':
api.getEffectsManager().toggleEffect(this._action.effect); api.getEffectsManager().toggleEffect(action.effect);
break; 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> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); 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> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); 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> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
const mediaPlayer = this._action.media_player; const action = this._getAction();
const mediaPlayer = action.media_player;
const mediaPlayerController = api.getMediaPlayerManager(); const mediaPlayerController = api.getMediaPlayerManager();
if (this._action.media_player_action === 'stop') { if (action.media_player_action === 'stop') {
await mediaPlayerController.stop(mediaPlayer); await mediaPlayerController.stop(mediaPlayer);
return; return;
} }
@@ -7,7 +7,7 @@ export class MoreInfoAction extends AdvancedCameraCardAction<MoreInfoActionConfi
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
const entityID = this._action.entity ?? this._config?.entity ?? null; const entityID = this._getAction().entity ?? this._config?.entity ?? null;
if (!entityID) { if (!entityID) {
return; return;
} }
@@ -7,13 +7,14 @@ export class NavigateAction extends AdvancedCameraCardAction<NavigateActionConfi
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
if (!!this._action.navigation_replace) { const action = this._getAction();
history.replaceState(null, '', this._action.navigation_path); if (!!action.navigation_replace) {
history.replaceState(null, '', action.navigation_path);
} else { } else {
history.pushState(null, '', this._action.navigation_path); history.pushState(null, '', action.navigation_path);
} }
fireHASSEvent(window, 'location-changed', { 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> { export class NotificationAction extends AdvancedCameraCardAction<NotificationActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); 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; return;
} }
const [domain, service] = this._action.perform_action.split('.', 2); const action = this._getAction();
await hass.callService(domain, service, this._action.data, this._action.target); 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> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
const action = this._getAction();
const currentEnabled = api.getViewManager().getView()?.context?.ptzControls?.enabled; const currentEnabled = api.getViewManager().getView()?.context?.ptzControls?.enabled;
// If `enabled` is explicit, use it. If only `type` is being changed, leave // 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 // toggle the current enabled value — this is the menu-button show/hide use
// case. // case.
const enabled = const enabled =
this._action.enabled ?? action.enabled ??
(this._action.type (action.type
? undefined ? undefined
: currentEnabled === undefined : currentEnabled === undefined
? undefined ? undefined
@@ -23,7 +24,7 @@ export class PTZControlsAction extends AdvancedCameraCardAction<PTZControlsActio
api.getViewManager().setViewWithMergedContext({ api.getViewManager().setViewWithMergedContext({
ptzControls: { ptzControls: {
...(enabled !== undefined && { enabled }), ...(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> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
const action = this._getAction();
const view = api.getViewManager().getView(); const view = api.getViewManager().getView();
if (!view) { if (!view) {
return; return;
} }
const targetID = const targetID =
this._action.target_id ?? action.target_id ??
getPTZTarget(view, { type: 'digital', cameraManager: api.getCameraManager() }) getPTZTarget(view, { type: 'digital', cameraManager: api.getCameraManager() })
?.targetID; ?.targetID;
if (!targetID) { if (!targetID) {
return; return;
} }
if (!!this._action.absolute || !this._action.ptz_phase) { if (!!action.absolute || !action.ptz_phase) {
return await this._stepChange(api, targetID); return await this._stepChange(api, targetID);
} }
/* istanbul ignore else: the else path cannot be reached -- @preserve */ /* 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); await stopInProgressForThisTarget(targetID, this._context.ptzDigital);
setInProgressForThisTarget(targetID, this._context, 'ptzDigital', this); setInProgressForThisTarget(targetID, this._context, 'ptzDigital', this);
@@ -74,21 +75,22 @@ export class PTZDigitalAction extends AdvancedCameraCardAction<PTZDigitialAction
this._timer.startRepeated(STEP_DELAY_SECONDS, () => this._timer.startRepeated(STEP_DELAY_SECONDS, () =>
this._stepChange(api, targetID), this._stepChange(api, targetID),
); );
} else if (this._action.ptz_phase === 'stop') { } else if (action.ptz_phase === 'stop') {
await stopInProgressForThisTarget(targetID, this._context.ptzDigital); await stopInProgressForThisTarget(targetID, this._context.ptzDigital);
delete this._context.ptzDigital?.[targetID]; delete this._context.ptzDigital?.[targetID];
} }
} }
private _convertActionToZoomSettings(base?: PartialZoomSettings): PartialZoomSettings { 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 // If neither an absolute position nor an action are specified, the request
// is assumed to be to return to default. // is assumed to be to return to default.
return {}; return {};
} }
if (this._action.absolute) { if (action.absolute) {
return this._action.absolute; return action.absolute;
} }
const zoom = base?.zoom ?? ZOOM_DEFAULT_SCALE; const zoom = base?.zoom ?? ZOOM_DEFAULT_SCALE;
@@ -98,21 +100,21 @@ export class PTZDigitalAction extends AdvancedCameraCardAction<PTZDigitialAction
}; };
const zoomDelta = const zoomDelta =
this._action.ptz_action === 'zoom_in' action.ptz_action === 'zoom_in'
? STEP_ZOOM ? STEP_ZOOM
: this._action.ptz_action === 'zoom_out' : action.ptz_action === 'zoom_out'
? -STEP_ZOOM ? -STEP_ZOOM
: 0; : 0;
const xDelta = const xDelta =
this._action.ptz_action === 'left' action.ptz_action === 'left'
? -STEP_PAN ? -STEP_PAN
: this._action.ptz_action === 'right' : action.ptz_action === 'right'
? STEP_PAN ? STEP_PAN
: 0; : 0;
const yDelta = const yDelta =
this._action.ptz_action === 'up' action.ptz_action === 'up'
? -STEP_PAN ? -STEP_PAN
: this._action.ptz_action === 'down' : action.ptz_action === 'down'
? STEP_PAN ? STEP_PAN
: 0; : 0;
@@ -14,8 +14,9 @@ export class PTZMultiAction extends AdvancedCameraCardAction<PTZMultiActionConfi
let targetID: string | null = null; let targetID: string | null = null;
let type: PTZType | null = null; let type: PTZType | null = null;
if (this._action.target_id) { const action = this._getAction();
targetID = this._action.target_id; if (action.target_id) {
targetID = action.target_id;
type = hasCameraTruePTZ(api.getCameraManager(), targetID) ? 'ptz' : 'digital'; type = hasCameraTruePTZ(api.getCameraManager(), targetID) ? 'ptz' : 'digital';
} else if (view) { } else if (view) {
const multiTarget = getPTZTarget(view, { cameraManager: api.getCameraManager() }); const multiTarget = getPTZTarget(view, { cameraManager: api.getCameraManager() });
@@ -34,26 +35,28 @@ export class PTZMultiAction extends AdvancedCameraCardAction<PTZMultiActionConfi
} }
private _toPTZAction(targetID: string): PTZAction { private _toPTZAction(targetID: string): PTZAction {
const action = this._getAction();
return new PTZAction( return new PTZAction(
this._context, this._context,
createPTZAction({ createPTZAction({
cardID: this._action.card_id, cardID: action.card_id,
cameraID: targetID, cameraID: targetID,
ptzAction: this._action.ptz_action, ptzAction: action.ptz_action,
ptzPhase: this._action.ptz_phase, ptzPhase: action.ptz_phase,
ptzPreset: this._action.ptz_preset, ptzPreset: action.ptz_preset,
}), }),
this._config, this._config,
); );
} }
private _toPTZDigitalAction(targetID: string): PTZDigitalAction { private _toPTZDigitalAction(targetID: string): PTZDigitalAction {
const action = this._getAction();
return new PTZDigitalAction( return new PTZDigitalAction(
this._context, this._context,
createPTZDigitalAction({ createPTZDigitalAction({
cardID: this._action.card_id, cardID: action.card_id,
ptzPhase: this._action.ptz_phase, ptzPhase: action.ptz_phase,
ptzAction: this._action.ptz_action, ptzAction: action.ptz_action,
targetID: targetID, targetID: targetID,
}), }),
this._config, this._config,
+21 -25
View File
@@ -33,13 +33,15 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
const action = this._getAction();
const view = api.getViewManager().getView(); const view = api.getViewManager().getView();
if (!view) { if (!view) {
return; return;
} }
const ptzCameraID = const ptzCameraID =
this._action.camera ?? action.camera ??
getPTZTarget(view, { type: 'ptz', cameraManager: api.getCameraManager() }) getPTZTarget(view, { type: 'ptz', cameraManager: api.getCameraManager() })
?.targetID ?? ?.targetID ??
null; null;
@@ -53,34 +55,34 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
return; return;
} }
if (!this._action.ptz_action) { if (!action.ptz_action) {
if (ptzCapabilities.presets && ptzCapabilities.presets.length >= 1) { if (ptzCapabilities.presets && ptzCapabilities.presets.length >= 1) {
await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', { await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', {
phase: this._action.ptz_phase, phase: action.ptz_phase,
preset: ptzCapabilities.presets[0], preset: ptzCapabilities.presets[0],
}); });
} }
return; return;
} }
const capabilityKey = ptzActionToCapabilityKey(this._action.ptz_action); const capabilityKey = ptzActionToCapabilityKey(action.ptz_action);
if ( if (
(capabilityKey && (capabilityKey &&
ptzCapabilities[capabilityKey]?.includes( 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. // Scenario: Camera natively supports requested move type.
return await api return await api
.getCameraManager() .getCameraManager()
.executePTZAction(ptzCameraID, this._action.ptz_action, { .executePTZAction(ptzCameraID, action.ptz_action, {
phase: this._action.ptz_phase, phase: action.ptz_phase,
preset: this._action.ptz_preset, 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. // Scenario: Asked to start a continuous move, camera only supports relative moves natively.
await stopInProgressForThisTarget(ptzCameraID, this._context.ptz); await stopInProgressForThisTarget(ptzCameraID, this._context.ptz);
setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this); setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this);
@@ -88,11 +90,9 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
const singleStep = async (): Promise<void> => { const singleStep = async (): Promise<void> => {
/* istanbul ignore else: the else path cannot be reached as ptz_action /* istanbul ignore else: the else path cannot be reached as ptz_action
being present is checked above -- @preserve */ being present is checked above -- @preserve */
if (this._action.ptz_action) { if (action.ptz_action) {
await api await api.getCameraManager().executePTZAction(ptzCameraID, action.ptz_action, {
.getCameraManager() preset: action.ptz_preset,
.executePTZAction(ptzCameraID, this._action.ptz_action, {
preset: this._action.ptz_preset,
}); });
} }
@@ -109,28 +109,24 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
this._stopped = false; this._stopped = false;
await singleStep(); 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. // Scenario: Asked to stop continuous move, camera only supports relative moves natively.
await stopInProgressForThisTarget(ptzCameraID, this._context.ptz); await stopInProgressForThisTarget(ptzCameraID, this._context.ptz);
} else { } else {
this._stopped = false; this._stopped = false;
// Relative move (but camera only supports continuous). // Relative move (but camera only supports continuous).
await api await api.getCameraManager().executePTZAction(ptzCameraID, action.ptz_action, {
.getCameraManager() preset: action.ptz_preset,
.executePTZAction(ptzCameraID, this._action.ptz_action, {
preset: this._action.ptz_preset,
phase: 'start', phase: 'start',
}); });
this._timer.start(ptzConfiguration.c2r_delay_between_calls_seconds, async () => { this._timer.start(ptzConfiguration.c2r_delay_between_calls_seconds, async () => {
/* istanbul ignore else: the else path cannot be reached as ptz_action /* istanbul ignore else: the else path cannot be reached as ptz_action
being present is checked above -- @preserve */ being present is checked above -- @preserve */
if (this._action.ptz_action) { if (action.ptz_action) {
await api await api.getCameraManager().executePTZAction(ptzCameraID, action.ptz_action, {
.getCameraManager() preset: action.ptz_preset,
.executePTZAction(ptzCameraID, this._action.ptz_action, {
preset: this._action.ptz_preset,
phase: 'stop', phase: 'stop',
}); });
} }
@@ -18,7 +18,7 @@ export class SetReviewAction extends AdvancedCameraCardAction<SetReviewActionCon
return; return;
} }
const targetReviewedState = this._action.reviewed; const targetReviewedState = this._getAction().reviewed;
if (targetReviewedState !== undefined && targetReviewedState === item.isReviewed()) { if (targetReviewedState !== undefined && targetReviewedState === item.isReviewed()) {
return; return;
} }
+30 -19
View File
@@ -1,34 +1,32 @@
import { ActionContext } from 'action'; import { ActionContext } from 'action';
import { import { ActionConfig } from '../../../config/schema/actions/types';
ActionConfig,
AuxillaryActionConfig,
} from '../../../config/schema/actions/types';
import { arrayify } from '../../../utils/basic'; import { arrayify } from '../../../utils/basic';
import { CardActionsAPI } from '../../types'; import { CardActionsAPI } from '../../types';
import { ActionFactory } from '../factory'; import { ActionFactory, ActionFactoryOptions } from '../factory';
import { Action } from '../types'; import { ActionPrepareCallback } from '../types';
export class ActionSet implements Action { interface ActionSetOptions {
factoryOptions?: ActionFactoryOptions;
actionPrepareCallback?: ActionPrepareCallback;
}
export class ActionSet {
private _context: ActionContext; private _context: ActionContext;
private _actions: Action[] = []; private _actions: ActionConfig[];
private _factoryOptions?: ActionFactoryOptions;
private _actionPrepareCallback?: ActionPrepareCallback;
private _factory = new ActionFactory(); private _factory = new ActionFactory();
private _stopped = false; private _stopped = false;
constructor( constructor(
context: ActionContext, context: ActionContext,
actions: ActionConfig | ActionConfig[], actions: ActionConfig | ActionConfig[],
options?: { options?: ActionSetOptions,
config?: AuxillaryActionConfig;
cardID?: string;
},
) { ) {
this._context = context; this._context = context;
for (const actionObj of arrayify(actions)) { this._actions = arrayify(actions);
const action = this._factory.createAction(context, actionObj, options); this._actionPrepareCallback = options?.actionPrepareCallback;
if (action) { this._factoryOptions = options?.factoryOptions;
this._actions.push(action);
}
}
} }
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
@@ -37,7 +35,20 @@ export class ActionSet implements Action {
break; 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> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); 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> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
switch (this._action.status_bar_action) { const action = this._getAction();
switch (action.status_bar_action) {
case 'reset': case 'reset':
api.getStatusBarItemManager().removeAllDynamicStatusBarItems(); api.getStatusBarItemManager().removeAllDynamicStatusBarItems();
break; break;
case 'add': case 'add':
this._action.items?.forEach((item) => action.items?.forEach((item) =>
api.getStatusBarItemManager().addDynamicStatusBarItem(item), api.getStatusBarItemManager().addDynamicStatusBarItem(item),
); );
break; break;
case 'remove': case 'remove':
this._action.items?.forEach((item) => action.items?.forEach((item) =>
api.getStatusBarItemManager().removeDynamicStatusBarItem(item), api.getStatusBarItemManager().removeDynamicStatusBarItem(item),
); );
break; break;
@@ -8,7 +8,7 @@ export class SubstreamOffAction extends AdvancedCameraCardAction<SubstreamOffAct
await super.execute(api); await super.execute(api);
api.getViewManager().setViewByParameters({ 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; return;
} }
const cameraID = this._action.camera ?? view.camera; const action = this._getAction();
const cameraID = action.camera ?? view.camera;
if (!cameraID) { if (!cameraID) {
return; return;
} }
const stream = const stream =
this._action.stream ?? action.stream ??
this._getCycledSubstreamID(view, cameraID, api.getCameraManager()); this._getCycledSubstreamID(view, cameraID, api.getCameraManager());
api.getViewManager().setViewByParameters({ api.getViewManager().setViewByParameters({
+1 -1
View File
@@ -6,6 +6,6 @@ export class URLAction extends AdvancedCameraCardAction<URLActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); 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> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
const action = this._getAction();
await api.getViewManager().setViewByParametersWithNewQuery({ await api.getViewManager().setViewByParametersWithNewQuery({
params: { params: {
view: this._action.advanced_camera_card_action, view: action.advanced_camera_card_action,
}, },
...(this._action.folder && { ...(action.folder && {
queryExecutorOptions: { queryExecutorOptions: {
folder: this._action.folder, folder: action.folder,
}, },
}), }),
}); });
+23 -7
View File
@@ -1,7 +1,8 @@
import { ActionContext } from 'action'; import { ActionContext } from 'action';
import { TriggerData } from '../../condition-trigger/triggers/types';
import { INTERNAL_CALLBACK_ACTION } from '../../config/schema/actions/custom/internal'; import { INTERNAL_CALLBACK_ACTION } from '../../config/schema/actions/custom/internal';
import { ActionConfig, AuxillaryActionConfig } from '../../config/schema/actions/types'; 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 { CallAnswerAction } from './actions/call-answer';
import { CallEndAction } from './actions/call-end'; import { CallEndAction } from './actions/call-end';
import { CallServiceAction } from './actions/call-service'; import { CallServiceAction } from './actions/call-service';
@@ -15,6 +16,7 @@ import { DownloadAction } from './actions/download';
import { EffectAction } from './actions/effect'; import { EffectAction } from './actions/effect';
import { ExpandAction } from './actions/expand'; import { ExpandAction } from './actions/expand';
import { FullscreenAction } from './actions/fullscreen'; import { FullscreenAction } from './actions/fullscreen';
import { IfAction } from './actions/if';
import { InfoAction } from './actions/info'; import { InfoAction } from './actions/info';
import { InternalCallbackAction } from './actions/internal-callback'; import { InternalCallbackAction } from './actions/internal-callback';
import { LogAction } from './actions/log'; import { LogAction } from './actions/log';
@@ -50,23 +52,36 @@ import { URLAction } from './actions/url';
import { ViewAction } from './actions/view'; import { ViewAction } from './actions/view';
import { Action } from './types'; 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 { export class ActionFactory {
public createAction( public createAction(
context: ActionContext, context: ActionContext,
action: ActionConfig, action: ActionConfig,
options?: { options?: ActionFactoryOptions,
config?: AuxillaryActionConfig;
cardID?: string;
},
): Action | null { ): Action | null {
if ( if (
// Command not intended for this card (e.g. query string command). // 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 &&
action.card_id !== options?.cardID action.card_id !== options?.cardID
) { ) {
return null; return null;
} }
if (isIfAction(action)) {
return new IfAction(context, action, options?.config, options?.triggerData);
}
switch (action.action) { switch (action.action) {
case 'more-info': case 'more-info':
return new MoreInfoAction(context, action, options?.config); return new MoreInfoAction(context, action, options?.config);
@@ -182,11 +197,12 @@ export class ActionFactory {
return new InternalCallbackAction(context, action, options?.config); 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( console.warn(
`Advanced Camera Card received unknown card action: ${action['advanced_camera_card_action']}`, `Advanced Camera Card received unknown card action: ${action['advanced_camera_card_action']}`,
); );
/* istanbul ignore next: this path cannot be reached -- @preserve */
return null; 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 { import {
ActionConfig, ActionConfig,
AuxillaryActionConfig, AuxillaryActionConfig,
@@ -6,7 +6,18 @@ import {
import { AdvancedCameraCardError } from '../../types.js'; import { AdvancedCameraCardError } from '../../types.js';
import { CardActionsAPI } from '../types'; 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 { 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>; execute(api: CardActionsAPI): Promise<void>;
stop(): Promise<void>; stop(): Promise<void>;
} }
@@ -14,7 +25,7 @@ export interface Action {
export interface ActionsExecutionRequest { export interface ActionsExecutionRequest {
actions: ActionConfig[] | ActionConfig; actions: ActionConfig[] | ActionConfig;
config?: AuxillaryActionConfig; config?: AuxillaryActionConfig;
triggerData?: ConditionsTriggerData; triggerData?: TriggerData;
} }
export interface ActionsExecutor { export interface ActionsExecutor {
+45 -22
View File
@@ -1,7 +1,10 @@
import { ConditionsManager } from '../conditions/conditions-manager.js'; import { ConditionEvaluator } from '../condition-trigger/conditions/conditions/types.js';
import { ConditionsEvaluationResult } from '../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 { Automation, AutomationActions } from '../config/schema/automations.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import { TemplateRenderer } from './templates/index.js';
import { CardAutomationsAPI, TaggedAutomation } from './types.js'; import { CardAutomationsAPI, TaggedAutomation } from './types.js';
const MAX_NESTED_AUTOMATION_EXECUTIONS = 10; const MAX_NESTED_AUTOMATION_EXECUTIONS = 10;
@@ -9,7 +12,7 @@ const MAX_NESTED_AUTOMATION_EXECUTIONS = 10;
export class AutomationsManager { export class AutomationsManager {
private _api: CardAutomationsAPI; 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, // A counter to avoid infinite loops, increases every time actions are run,
// decreases every time actions are complete. // decreases every time actions are complete.
@@ -20,28 +23,39 @@ export class AutomationsManager {
} }
public deleteAutomations(tag?: unknown) { public deleteAutomations(tag?: unknown) {
for (const [automation, conditionManager] of this._automations) { for (const [automation, triggers] of this._automations) {
if (automation.tag === tag) { if (automation.tag === tag) {
this._automations.delete(automation); this._automations.delete(automation);
conditionManager.destroy(); triggers.destroy();
} }
} }
} }
public addAutomations(automations: TaggedAutomation[]): void { public addAutomations(automations: TaggedAutomation[]): void {
const context = { templateRenderer: new TemplateRenderer() };
for (const automation of automations) { for (const automation of automations) {
const conditionManager = new ConditionsManager( const triggers = new TriggersManager(
automation.conditions, automation.triggers,
this._api.getConditionStateManager(), 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 ( if (
!this._api.getHASSManager().hasHASS() || !this._api.getHASSManager().hasHASS() ||
// Never execute automations if the card hasn't finished initializing, as // Never execute automations if the card hasn't finished initializing, as
@@ -55,17 +69,25 @@ export class AutomationsManager {
return; return;
} }
const shouldExecute = result.result; // Evaluate the ongoing conditions against the current state at the instant
const actions = shouldExecute ? automation.actions : automation.actions_not; // 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; return;
} }
const runActions = async (actions: AutomationActions): Promise<void> => { const runActions = async (actions: AutomationActions): Promise<void> => {
++this._nestedAutomationExecutions; // Check the limit *before* incrementing, so the overflow path holds no
// increment to leak; the `finally` then guarantees the decrement even if
if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) { // 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({ this._api.getNotificationManager().setNotification({
heading: { heading: {
text: localize('error.too_many_automations'), text: localize('error.too_many_automations'),
@@ -76,12 +98,13 @@ export class AutomationsManager {
return; return;
} }
await this._api ++this._nestedAutomationExecutions;
.getActionsManager() try {
.executeActions({ actions, triggerData: result.triggerData }); await this._api.getActionsManager().executeActions({ actions, triggerData });
} finally {
--this._nestedAutomationExecutions; --this._nestedAutomationExecutions;
}
}; };
runActions(actions); runActions(automation.actions);
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import { createNotificationFromText } from '../../components-lib/notification/factory'; 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 { localize } from '../../localize/localize';
import { Timer } from '../../utils/timer'; import { Timer } from '../../utils/timer';
import { getStreamCameraID } from '../../view/substream'; import { getStreamCameraID } from '../../view/substream';
@@ -2,7 +2,7 @@ import { maxBy, throttle } from 'lodash-es';
import { CameraEvent } from '../camera-manager/types'; import { CameraEvent } from '../camera-manager/types';
import { isTriggeredState } from '../ha/is-triggered-state'; import { isTriggeredState } from '../ha/is-triggered-state';
import { Timer } from '../utils/timer'; import { Timer } from '../utils/timer';
import { CardTriggersAPI } from './types'; import { CardCameraTriggersAPI } from './types';
interface CameraTriggerState { interface CameraTriggerState {
// The time of the most recent trigger event. Used to determine the most // The time of the most recent trigger event. Used to determine the most
@@ -26,15 +26,15 @@ interface CameraTriggerState {
untriggerForceTimer?: Timer; untriggerForceTimer?: Timer;
} }
export class TriggersManager { export class CameraTriggersManager {
private _api: CardTriggersAPI; private _api: CardCameraTriggersAPI;
private _states: Map<string, CameraTriggerState> = new Map(); private _states: Map<string, CameraTriggerState> = new Map();
private _throttledTriggerAction = throttle(this._triggerAction.bind(this), 1000, { private _throttledTriggerAction = throttle(this._triggerAction.bind(this), 1000, {
trailing: true, trailing: true,
}); });
constructor(api: CardTriggersAPI) { constructor(api: CardCameraTriggersAPI) {
this._api = api; this._api = api;
} }
+1 -1
View File
@@ -188,7 +188,7 @@ export class CardElementManager {
// reconnection, to ensure the state subscription/unsubscription works // reconnection, to ensure the state subscription/unsubscription works
// correctly and triggers that changed while detached are picked up. // correctly and triggers that changed while detached are picked up.
// Reset trigger state first to stop stale timers and clear condition state. // 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.getCallManager().uninitialize();
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS); this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
@@ -22,9 +22,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
const automations: TaggedAutomation[] = [ const automations: TaggedAutomation[] = [
{ {
conditions: [ triggers: [
{ {
condition: 'config' as const, trigger: 'config' as const,
paths: ['cameras', 'remote_control.entities.camera'], paths: ['cameras', 'remote_control.entities.camera'],
}, },
], ],
@@ -38,9 +38,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
tag: automationTag, tag: automationTag,
}, },
{ {
conditions: [ triggers: [
{ {
condition: 'camera' as const, trigger: 'camera' as const,
}, },
], ],
actions: [ actions: [
@@ -63,9 +63,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
// this one, as automations only run *after* the card is initialized (and // 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 // it very likely will not yet be). Instead, wait to be initialized, then
// set the camera. // set the camera.
conditions: [ triggers: [
{ {
condition: 'initialized' as const, trigger: 'initialized' as const,
}, },
], ],
actions: [ actions: [
@@ -81,15 +81,15 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
tag: automationTag, tag: automationTag,
}, },
{ {
conditions: [ triggers: [
{ {
condition: 'state' as const, trigger: 'state' as const,
entity: cameraControlEntity, entity_id: cameraControlEntity,
}, },
], ],
actions: [ actions: [
// When the entity state changes, updated the selected option. // When the entity state changes, updated the selected option.
createCameraAction('{{ advanced_camera_card.trigger.state.to }}'), createCameraAction('{{ trigger.to_state.state }}'),
], ],
tag: automationTag, tag: automationTag,
}, },
@@ -64,9 +64,9 @@ const convertKeyboardShortcutsToAutomations = (
} }
automations.push({ automations.push({
conditions: [ triggers: [
{ {
condition: 'key' as const, trigger: 'key' as const,
key: shortcut.key, key: shortcut.key,
state: 'down', state: 'down',
shift: shortcut.shift, shift: shortcut.shift,
@@ -85,9 +85,9 @@ const convertKeyboardShortcutsToAutomations = (
}); });
automations.push({ automations.push({
conditions: [ triggers: [
{ {
condition: 'key' as const, trigger: 'key' as const,
key: shortcut.key, key: shortcut.key,
state: 'up', state: 'up',
}, },
@@ -105,9 +105,9 @@ const convertKeyboardShortcutsToAutomations = (
const homeShortcut = shortcuts.ptz_home; const homeShortcut = shortcuts.ptz_home;
if (homeShortcut) { if (homeShortcut) {
automations.push({ automations.push({
conditions: [ triggers: [
{ {
condition: 'key' as const, trigger: 'key' as const,
key: homeShortcut.key, key: homeShortcut.key,
state: 'down', state: 'down',
shift: homeShortcut.shift, shift: homeShortcut.shift,
@@ -1,6 +1,6 @@
import { get, merge } from 'lodash-es'; import { get, merge } from 'lodash-es';
import { ConditionsManager } from '../../conditions/conditions-manager'; import { ConditionsManager } from '../../condition-trigger/conditions/conditions-manager';
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types'; import { ConditionStateManagerReadonlyInterface } from '../../condition-trigger/conditions/types';
import { import {
copyConfig, copyConfig,
deleteConfigValue, deleteConfigValue,
+7 -7
View File
@@ -1,6 +1,6 @@
import { ReactiveController } from 'lit'; import { ReactiveController } from 'lit';
import { CameraManager } from '../camera-manager/manager'; 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 { AdvancedCameraCardConfig } from '../config/schema/types';
import { DeviceRegistryManager } from '../ha/registry/device'; import { DeviceRegistryManager } from '../ha/registry/device';
import { DeviceCache } from '../ha/registry/device/types'; import { DeviceCache } from '../ha/registry/device/types';
@@ -11,6 +11,7 @@ import { LovelaceCardEditor } from '../ha/types';
import { ActionsManager } from './actions/actions-manager'; import { ActionsManager } from './actions/actions-manager';
import { AutomationsManager } from './automations-manager'; import { AutomationsManager } from './automations-manager';
import { CallManager } from './call/manager'; import { CallManager } from './call/manager';
import { CameraTriggersManager } from './camera-triggers-manager';
import { CameraURLManager } from './camera-url-manager'; import { CameraURLManager } from './camera-url-manager';
import { import {
CardElementManager, CardElementManager,
@@ -40,11 +41,11 @@ import { QueryStringManager } from './query-string-manager';
import { StatusBarItemManager } from './status-bar-item-manager'; import { StatusBarItemManager } from './status-bar-item-manager';
import { StyleManager } from './style-manager'; import { StyleManager } from './style-manager';
import { TemplateRenderer } from './templates'; import { TemplateRenderer } from './templates';
import { TriggersManager } from './triggers-manager';
import { import {
CardActionsManagerAPI, CardActionsManagerAPI,
CardAutomationsAPI, CardAutomationsAPI,
CardCameraAPI, CardCameraAPI,
CardCameraTriggersAPI,
CardCameraURLAPI, CardCameraURLAPI,
CardConditionAPI, CardConditionAPI,
CardConfigAPI, CardConfigAPI,
@@ -66,7 +67,6 @@ import {
CardPIPAPI, CardPIPAPI,
CardQueryStringAPI, CardQueryStringAPI,
CardStyleAPI, CardStyleAPI,
CardTriggersAPI,
CardViewAPI, CardViewAPI,
} from './types'; } from './types';
import { ViewItemManager } from './view/item-manager'; import { ViewItemManager } from './view/item-manager';
@@ -98,7 +98,7 @@ export class CardController
CardNotificationAPI, CardNotificationAPI,
CardQueryStringAPI, CardQueryStringAPI,
CardStyleAPI, CardStyleAPI,
CardTriggersAPI, CardCameraTriggersAPI,
CardViewAPI, CardViewAPI,
ReactiveController ReactiveController
{ {
@@ -137,7 +137,7 @@ export class CardController
private _queryStringManager = new QueryStringManager(this); private _queryStringManager = new QueryStringManager(this);
private _statusBarItemManager = new StatusBarItemManager(this); private _statusBarItemManager = new StatusBarItemManager(this);
private _styleManager = new StyleManager(this); private _styleManager = new StyleManager(this);
private _triggersManager = new TriggersManager(this); private _cameraTriggersManager = new CameraTriggersManager(this);
private _viewManager = new ViewManager(this); private _viewManager = new ViewManager(this);
private _viewItemManager = new ViewItemManager(this); private _viewItemManager = new ViewItemManager(this);
@@ -305,8 +305,8 @@ export class CardController
return this._styleManager; return this._styleManager;
} }
public getTriggersManager(): TriggersManager { public getCameraTriggersManager(): CameraTriggersManager {
return this._triggersManager; return this._cameraTriggersManager;
} }
public getViewManager(): ViewManager { public getViewManager(): ViewManager {
+2 -2
View File
@@ -55,9 +55,9 @@ export class DefaultManager {
this._api.getAutomationsManager().addAutomations([ this._api.getAutomationsManager().addAutomations([
{ {
actions: [createGeneralAction('default')], actions: [createGeneralAction('default')],
conditions: [ triggers: [
{ {
condition: 'interaction' as const, trigger: 'interaction' as const,
interaction: false, 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 { FolderConfig, FolderType, folderTypeSchema } from '../../config/schema/folders';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types'; import { Endpoint } from '../../types';
+1 -1
View File
@@ -1,6 +1,6 @@
import { sub } from 'date-fns'; import { sub } from 'date-fns';
import { NonEmptyTuple } from 'type-fest'; import { NonEmptyTuple } from 'type-fest';
import { ConditionState } from '../../../conditions/types'; import { ConditionState } from '../../../condition-trigger/conditions/types';
import { import {
FolderConfig, FolderConfig,
folderTypeSchema, folderTypeSchema,
@@ -1,5 +1,5 @@
import { sub } from 'date-fns'; import { sub } from 'date-fns';
import { ConditionState } from '../../../conditions/types'; import { ConditionState } from '../../../condition-trigger/conditions/types';
import { import {
DateMatcher, DateMatcher,
Matcher, Matcher,
+1 -1
View File
@@ -1,5 +1,5 @@
import { cloneDeep } from 'lodash-es'; 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 { FolderConfig, FolderConfigWithoutID } from '../../config/schema/folders';
import { localize } from '../../localize/localize'; import { localize } from '../../localize/localize';
import { hasUnsupportedFilters } from '../../query-source.js'; import { hasUnsupportedFilters } from '../../query-source.js';
+1 -1
View File
@@ -1,5 +1,5 @@
import { NonEmptyTuple } from 'type-fest'; 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 { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
import { ResolvedMediaCache } from '../../ha/resolved-media'; import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types'; 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 { WebkitHTMLVideoElement } from '../../../types';
import { Timer } from '../../../utils/timer'; import { Timer } from '../../../utils/timer';
import { FullscreenProviderBase } from '../provider'; import { FullscreenProviderBase } from '../provider';
+1 -1
View File
@@ -1,6 +1,6 @@
import { HassEvent } from 'home-assistant-js-websocket'; import { HassEvent } from 'home-assistant-js-websocket';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
import { KeyedSubscriptionManager } from '../../utils/keyed-subscription-manager'; import { KeyedSubscriptionManager } from '../../utils/concurrency/keyed-subscription-manager';
export interface EventSubscriptionRequest { export interface EventSubscriptionRequest {
event_type: string; event_type: string;
@@ -160,7 +160,7 @@ export class InitializationManager {
this._initializer.initializeIfNecessary( this._initializer.initializeIfNecessary(
InitializationAspect.INITIAL_TRIGGER, InitializationAspect.INITIAL_TRIGGER,
async () => { async () => {
await this._api.getTriggersManager().handleInitialCameraTriggers(); await this._api.getCameraTriggersManager().handleInitialCameraTriggers();
// Force a card update to continue the initialization. // Force a card update to continue the initialization.
this._api.getCardElementManager().update(); this._api.getCardElementManager().update();
+2
View File
@@ -2,6 +2,7 @@ import { CardIssueManagerAPI } from '../types';
import { IssueManager } from './issue-manager'; import { IssueManager } from './issue-manager';
import { ConfigErrorIssue } from './issues/config-error'; import { ConfigErrorIssue } from './issues/config-error';
import { ConfigUpgradeIssue } from './issues/config-upgrade'; import { ConfigUpgradeIssue } from './issues/config-upgrade';
import { ConfigUpgradeFailureIssue } from './issues/config-upgrade-failure';
import { ConnectionIssue } from './issues/connection'; import { ConnectionIssue } from './issues/connection';
import { InitializationIssue } from './issues/initialization'; import { InitializationIssue } from './issues/initialization';
import { LegacyResourceIssue } from './issues/legacy-resource'; 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. // full-card issue. Register broader/more critical issues first.
manager.addIssue(new ConfigErrorIssue()); manager.addIssue(new ConfigErrorIssue());
manager.addIssue(new ConfigUpgradeIssue(api)); manager.addIssue(new ConfigUpgradeIssue(api));
manager.addIssue(new ConfigUpgradeFailureIssue(api));
manager.addIssue(new ViewIncompatibleIssue(api)); manager.addIssue(new ViewIncompatibleIssue(api));
manager.addIssue(new ConnectionIssue()); manager.addIssue(new ConnectionIssue());
manager.addIssue(new InitializationIssue(api)); manager.addIssue(new InitializationIssue(api));
+1 -1
View File
@@ -1,5 +1,5 @@
import type { IssueTriggerContext } from 'issue'; import type { IssueTriggerContext } from 'issue';
import { ConditionStateChange } from '../../conditions/types'; import { ConditionStateChange } from '../../condition-trigger/conditions/types';
import { isActionAllowedBasedOnInteractionState } from '../../utils/interaction-mode'; import { isActionAllowedBasedOnInteractionState } from '../../utils/interaction-mode';
import { Timer } from '../../utils/timer'; import { Timer } from '../../utils/timer';
import { CardIssueManagerAPI } from '../types'; 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 { 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 { localize } from '../../../localize/localize.js';
import { Issue, IssueDescription } from '../types.js'; import { Issue, IssueDescription } from '../types.js';
@@ -1,5 +1,5 @@
import type { IssueTriggerContext } from 'issue'; 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 { Notification } from '../../../config/schema/actions/types.js';
import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js'; import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js';
import { localize } from '../../../localize/localize.js'; import { localize } from '../../../localize/localize.js';
+1 -1
View File
@@ -1,6 +1,6 @@
import type { IssueTriggerContext } from 'issue'; import type { IssueTriggerContext } from 'issue';
import { summarizeNotification } from '../../components-lib/notification/summarize'; 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 { Notification } from '../../config/schema/actions/types';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
import { isTruthy } from '../../utils/basic'; import { isTruthy } from '../../utils/basic';
+2 -1
View File
@@ -1,5 +1,5 @@
import type { IssueTriggerContext } from 'issue'; 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 { Notification } from '../../config/schema/actions/types';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
import { Severity } from '../../severity'; import { Severity } from '../../severity';
@@ -7,6 +7,7 @@ import { Severity } from '../../severity';
export type IssueKey = export type IssueKey =
| 'config_error' | 'config_error'
| 'config_upgrade' | 'config_upgrade'
| 'config_upgrade_failure'
| 'connection' | 'connection'
| 'initialization' | 'initialization'
| 'legacy_resource' | '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 { PIPElement } from '../types';
import { CardPIPAPI } from './types'; import { CardPIPAPI } from './types';
+14 -21
View File
@@ -1,28 +1,20 @@
import { HASS, renderTemplate } from 'ha-nunjucks/dist'; 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'; import { HomeAssistant } from '../../ha/types';
import { isRecord } from '../../utils/basic';
interface TemplateMediaData { import { TemplateACCNamespace, TemplateMediaData } from './types';
title: string;
is_folder: boolean;
}
interface TemplateContextInternal {
camera?: string;
view?: string;
trigger?: ConditionsTriggerData;
media?: TemplateMediaData;
}
interface TemplateContext { interface TemplateContext {
advanced_camera_card: TemplateContextInternal; acc: TemplateACCNamespace;
// Convenient alias. // The HA-native top-level `trigger`, set only when a trigger fired.
acc: TemplateContextInternal; trigger?: TriggerData;
} }
interface TemplateRenderOptions { interface TemplateRenderOptions {
conditionState?: ConditionState; conditionState?: ConditionState;
triggerData?: ConditionsTriggerData; triggerData?: TriggerData;
mediaData?: TemplateMediaData; mediaData?: TemplateMediaData;
} }
@@ -45,22 +37,23 @@ export class TemplateRenderer {
if ( if (
!options?.conditionState?.camera && !options?.conditionState?.camera &&
!options?.conditionState?.view && !options?.conditionState?.view &&
!options?.conditionState?.config &&
!options?.triggerData && !options?.triggerData &&
!options?.mediaData !options?.mediaData
) { ) {
return; return;
} }
const advancedCameraCardContext: TemplateContextInternal = { const acc: TemplateACCNamespace = {
...(options?.conditionState?.camera && { camera: options.conditionState.camera }), ...(options?.conditionState?.camera && { camera: options.conditionState.camera }),
...(options?.conditionState?.view && { view: options.conditionState.view }), ...(options?.conditionState?.view && { view: options.conditionState.view }),
...(options?.triggerData && { trigger: options.triggerData }), ...(options?.conditionState?.config && { config: options.conditionState.config }),
...(options?.mediaData && { media: options.mediaData }), ...(options?.mediaData && { media: options.mediaData }),
}; };
return { return {
acc: advancedCameraCardContext, acc,
advanced_camera_card: advancedCameraCardContext, ...(options?.triggerData && { trigger: options.triggerData }),
}; };
} }
@@ -81,7 +74,7 @@ export class TemplateRenderer {
return data.map((item) => return data.map((item) =>
this._renderTemplateRecursively(hass, item, templateContext), this._renderTemplateRecursively(hass, item, templateContext),
); );
} else if (typeof data === 'object' && data !== null) { } else if (isRecord(data)) {
const result = {}; const result = {};
for (const key in data) { for (const key in data) {
result[key] = this._renderTemplateRecursively(hass, data[key], templateContext); 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 { 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 { Automation } from '../config/schema/automations';
import type { DeviceRegistryManager } from '../ha/registry/device'; import type { DeviceRegistryManager } from '../ha/registry/device';
import type { EntityRegistryManager } from '../ha/registry/entity/types'; 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 { ActionsManager } from './actions/actions-manager';
import type { AutomationsManager } from './automations-manager'; import type { AutomationsManager } from './automations-manager';
import type { CallManager } from './call/manager'; import type { CallManager } from './call/manager';
import type { CameraTriggersManager } from './camera-triggers-manager';
import type { CameraURLManager } from './camera-url-manager'; import type { CameraURLManager } from './camera-url-manager';
import type { CardElementManager } from './card-element-manager'; import type { CardElementManager } from './card-element-manager';
import type { ConfigManager } from './config/config-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 { QueryStringManager } from './query-string-manager';
import type { StatusBarItemManager } from './status-bar-item-manager'; import type { StatusBarItemManager } from './status-bar-item-manager';
import type { StyleManager } from './style-manager'; import type { StyleManager } from './style-manager';
import type { TriggersManager } from './triggers-manager';
import type { ViewItemManager } from './view/item-manager'; import type { ViewItemManager } from './view/item-manager';
import type { ViewManager } from './view/view-manager'; import type { ViewManager } from './view/view-manager';
@@ -61,7 +61,7 @@ export interface CardActionsAPI {
getPIPManager(): PIPManager; getPIPManager(): PIPManager;
getIssueManager(): IssueManager; getIssueManager(): IssueManager;
getStatusBarItemManager(): StatusBarItemManager; getStatusBarItemManager(): StatusBarItemManager;
getTriggersManager(): TriggersManager; getCameraTriggersManager(): CameraTriggersManager;
getViewItemManager(): ViewItemManager; getViewItemManager(): ViewItemManager;
getViewManager(): ViewManager; getViewManager(): ViewManager;
} }
@@ -94,7 +94,7 @@ export interface CardCameraAPI {
getEntityRegistryManager(): EntityRegistryManager; getEntityRegistryManager(): EntityRegistryManager;
getHASSManager(): HASSManager; getHASSManager(): HASSManager;
getResolvedMediaCache(): ResolvedMediaCache; getResolvedMediaCache(): ResolvedMediaCache;
getTriggersManager(): TriggersManager; getCameraTriggersManager(): CameraTriggersManager;
} }
export interface CardCameraURLAPI { export interface CardCameraURLAPI {
@@ -139,7 +139,7 @@ export interface CardDefaultManagerAPI {
getConfigManager(): ConfigManager; getConfigManager(): ConfigManager;
getHASSManager(): HASSManager; getHASSManager(): HASSManager;
getInteractionManager(): InteractionManager; getInteractionManager(): InteractionManager;
getTriggersManager(): TriggersManager; getCameraTriggersManager(): CameraTriggersManager;
getViewManager(): ViewManager; getViewManager(): ViewManager;
} }
@@ -170,7 +170,7 @@ export interface CardElementAPI {
getPIPManager(): PIPManager; getPIPManager(): PIPManager;
getIssueManager(): IssueManager; getIssueManager(): IssueManager;
getQueryStringManager(): QueryStringManager; getQueryStringManager(): QueryStringManager;
getTriggersManager(): TriggersManager; getCameraTriggersManager(): CameraTriggersManager;
getViewManager(): ViewManager; getViewManager(): ViewManager;
} }
@@ -209,7 +209,7 @@ export interface CardHASSAPI {
getInteractionManager(): InteractionManager; getInteractionManager(): InteractionManager;
getMediaPlayerManager(): MediaPlayerManager; getMediaPlayerManager(): MediaPlayerManager;
getStyleManager(): StyleManager; getStyleManager(): StyleManager;
getTriggersManager(): TriggersManager; getCameraTriggersManager(): CameraTriggersManager;
getViewManager(): ViewManager; getViewManager(): ViewManager;
} }
@@ -230,7 +230,7 @@ export interface CardInitializerAPI {
getIssueManager(): IssueManager; getIssueManager(): IssueManager;
getQueryStringManager(): QueryStringManager; getQueryStringManager(): QueryStringManager;
getResolvedMediaCache(): ResolvedMediaCache; getResolvedMediaCache(): ResolvedMediaCache;
getTriggersManager(): TriggersManager; getCameraTriggersManager(): CameraTriggersManager;
getViewManager(): ViewManager; getViewManager(): ViewManager;
} }
@@ -239,7 +239,7 @@ export interface CardInteractionAPI {
getConditionStateManager(): ConditionStateManager; getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager; getConfigManager(): ConfigManager;
getStyleManager(): StyleManager; getStyleManager(): StyleManager;
getTriggersManager(): TriggersManager; getCameraTriggersManager(): CameraTriggersManager;
getViewManager(): ViewManager; getViewManager(): ViewManager;
} }
@@ -314,7 +314,7 @@ export interface CardStyleAPI {
getViewManager(): ViewManager; getViewManager(): ViewManager;
} }
export interface CardTriggersAPI { export interface CardCameraTriggersAPI {
getCallManager(): CallManager; getCallManager(): CallManager;
getCameraManager(): CameraManager; getCameraManager(): CameraManager;
getConditionStateManager(): ConditionStateManager; getConditionStateManager(): ConditionStateManager;
@@ -339,7 +339,7 @@ export interface CardViewAPI {
getIssueManager(): IssueManager; getIssueManager(): IssueManager;
getQueryStringManager(): QueryStringManager; getQueryStringManager(): QueryStringManager;
getStyleManager(): StyleManager; 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/thumbnail-carousel.js';
import './components/views.js'; import './components/views.js';
import { AdvancedCameraCardViews } from './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 { StatusBarItem } from './config/schema/actions/types.js';
import { MenuItem } from './config/schema/elements/custom/menu/types.js'; import { MenuItem } from './config/schema/elements/custom/menu/types.js';
import { AdvancedCameraCardConfig } from './config/schema/types.js'; import { AdvancedCameraCardConfig } from './config/schema/types.js';
@@ -457,7 +457,7 @@ class AdvancedCameraCard extends LitElement {
.locked=${this._controller.getLockManager().isLocked()} .locked=${this._controller.getLockManager().isLocked()}
.conditionStateManager=${this._controller.getConditionStateManager()} .conditionStateManager=${this._controller.getConditionStateManager()}
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status .triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
? this._controller.getTriggersManager().getTriggeredCameraIDs() ? this._controller.getCameraTriggersManager().getTriggeredCameraIDs()
: undefined} : undefined}
.deviceRegistryManager=${this._controller.getDeviceRegistryManager()} .deviceRegistryManager=${this._controller.getDeviceRegistryManager()}
.issues=${this._controller .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 { MenuItem } from '../config/schema/elements/custom/menu/types.js';
import type { MenuConfig } from '../config/schema/menu.js'; import type { MenuConfig } from '../config/schema/menu.js';
import type { Interaction } from '../types.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 { arrayify, isTruthy } from '../utils/basic.js';
import { AutoHideState, isAutoHidden as evaluateAutoHidden } from './auto-hide.js'; import { AutoHideState, isAutoHidden as evaluateAutoHidden } from './auto-hide.js';
@@ -225,6 +225,7 @@ export class MenuController {
private _isMenuToggleAction(action: ActionConfig): boolean { private _isMenuToggleAction(action: ActionConfig): boolean {
return ( return (
isStandardAction(action) &&
action.action === 'fire-dom-event' && action.action === 'fire-dom-event' &&
action.advanced_camera_card_action === 'menu_toggle' 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 { ViewItemManager } from '../../card-controller/view/item-manager';
import { MergeContextViewModifier } from '../../card-controller/view/modifiers/merge-context'; import { MergeContextViewModifier } from '../../card-controller/view/modifiers/merge-context';
import { ViewManagerEpoch } from '../../card-controller/view/types'; 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 { CameraConfig } from '../../config/schema/cameras';
import { AdvancedCameraCardView } from '../../config/schema/common/const'; import { AdvancedCameraCardView } from '../../config/schema/common/const';
import { ThumbnailsControlBaseConfig } from '../../config/schema/common/controls/thumbnails'; 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 { capEndDate } from '../../camera-manager/utils/cap-end-date';
import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly'; import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly';
import { FoldersManager } from '../../card-controller/folders/manager'; 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 { FolderConfig } from '../../config/schema/folders';
import { errorToConsole } from '../../utils/basic.js'; import { errorToConsole } from '../../utils/basic.js';
import { ViewItem, ViewMedia } from '../../view/item'; 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 { isEqual } from 'lodash-es';
import { IssueTriggerEventData } from '../card-controller/issues/types.js'; import { IssueTriggerEventData } from '../card-controller/issues/types.js';
import { TemplateRenderer } from '../card-controller/templates/index.js'; import { TemplateRenderer } from '../card-controller/templates/index.js';
import { ConditionsManager } from '../conditions/conditions-manager.js'; import { ConditionsManager } from '../condition-trigger/conditions/conditions-manager.js';
import { getConditionStateManagerViaEvent } from '../conditions/state-manager-via-event.js'; import { getConditionStateManagerViaEvent } from '../condition-trigger/conditions/state-manager-via-event.js';
import { ConditionStateManager } from '../conditions/state-manager.js'; import { ConditionStateManager } from '../condition-trigger/conditions/state-manager.js';
import { import {
StatusBarIcon, StatusBarIcon,
StatusBarImage, StatusBarImage,
+1 -1
View File
@@ -22,7 +22,7 @@ import {
navigateToMedia, navigateToMedia,
navigateUp, navigateUp,
} from '../../components-lib/navigation.js'; } 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 { MediaGalleryConfig } from '../../config/schema/media-gallery.js';
import { CardWideConfig } from '../../config/schema/types.js'; import { CardWideConfig } from '../../config/schema/types.js';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const.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 { FoldersManager } from '../card-controller/folders/manager.js';
import { ViewItemManager } from '../card-controller/view/item-manager.js'; import { ViewItemManager } from '../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../card-controller/view/types.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 { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js';
import { MiniTimelineControlConfig } from '../config/schema/common/controls/timeline.js'; import { MiniTimelineControlConfig } from '../config/schema/common/controls/timeline.js';
import { CardWideConfig } from '../config/schema/types.js'; import { CardWideConfig } from '../config/schema/types.js';
+1 -1
View File
@@ -19,7 +19,7 @@ import {
ThumbnailDataRequestEvent, ThumbnailDataRequestEvent,
TimelineItemClickAction, TimelineItemClickAction,
} from '../components-lib/timeline/types'; } 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 { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails';
import { TimelineCoreConfig } from '../config/schema/common/controls/timeline'; import { TimelineCoreConfig } from '../config/schema/common/controls/timeline';
import { CardWideConfig } from '../config/schema/types'; 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 { FoldersManager } from '../card-controller/folders/manager';
import { ViewItemManager } from '../card-controller/view/item-manager'; import { ViewItemManager } from '../card-controller/view/item-manager';
import { ViewManagerEpoch } from '../card-controller/view/types'; 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 { TimelineConfig } from '../config/schema/timeline';
import { CardWideConfig } from '../config/schema/types'; import { CardWideConfig } from '../config/schema/types';
import { HomeAssistant } from '../ha/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 { ViewItemManager } from '../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js'; import { ViewManagerEpoch } from '../card-controller/view/types.js';
import { CallSession } from '../card-controller/call/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 { AdvancedCameraCardConfig, CardWideConfig } from '../config/schema/types.js';
import { RawAdvancedCameraCardConfig } from '../config/types.js'; import { RawAdvancedCameraCardConfig } from '../config/types.js';
import { DeviceRegistryManager } from '../ha/registry/device/index.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 { ConditionsEvaluationResult, ConditionState } from '../types';
import { ConditionEvaluator, ConditionOfType } from './types'; import { ConditionEvaluator } from './types';
export class CallConditionEvaluator implements ConditionEvaluator { export class CallConditionEvaluator implements ConditionEvaluator {
private _condition: ConditionOfType<'call'>; private _condition: CallBase;
constructor(condition: ConditionOfType<'call'>) { constructor(condition: CallBase) {
this._condition = condition; this._condition = condition;
} }
public evaluate(newState?: ConditionState): ConditionsEvaluationResult { public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
return { 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) };
}
}

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