diff --git a/docs/configuration/README.md b/docs/configuration/README.md index e5ec1f5d..96b05464 100644 --- a/docs/configuration/README.md +++ b/docs/configuration/README.md @@ -53,7 +53,7 @@ render and can operate in `image` or `folders` views depending on configuration. #### Common configuration blocks -| Option | Description | -| ------------------------------ | --------------------- | -| [`actions`](actions/README.md) | Configure actions. | -| [`conditions`](conditions.md) | Configure conditions. | +| Option | Description | +| -------------------------------------- | --------------------- | +| [`actions`](actions/README.md) | Configure actions. | +| [`conditions`](conditions-triggers.md) | Configure conditions. | diff --git a/docs/configuration/_sidebar.md b/docs/configuration/_sidebar.md index bf360e84..1a52f297 100644 --- a/docs/configuration/_sidebar.md +++ b/docs/configuration/_sidebar.md @@ -3,7 +3,7 @@ - [`actions`](actions/README.md) - [`automations`](automations.md) - [`cameras`](cameras/README.md) - - [`conditions`](conditions.md) + - [`conditions` / `triggers`](conditions-triggers.md) - [`dimensions`](dimensions.md) - [`elements`](elements/README.md) - [`folders`](folders.md) diff --git a/docs/configuration/actions/_sidebar.md b/docs/configuration/actions/_sidebar.md index 21e53312..f7326979 100644 --- a/docs/configuration/actions/_sidebar.md +++ b/docs/configuration/actions/_sidebar.md @@ -5,7 +5,7 @@ - [Stock Actions](./stock/README.md) - [`automations`](../automations.md) - [`cameras`](../cameras/README.md) - - [`conditions`](../conditions.md) + - [`conditions` / `triggers`](../conditions-triggers.md) - [`dimensions`](../dimensions.md) - [`elements`](../elements/README.md) - [`folders`](../folders.md) diff --git a/docs/configuration/actions/custom/_sidebar.md b/docs/configuration/actions/custom/_sidebar.md index 910f3716..3759a144 100644 --- a/docs/configuration/actions/custom/_sidebar.md +++ b/docs/configuration/actions/custom/_sidebar.md @@ -5,7 +5,7 @@ - [Stock Actions](../stock/README.md) - [`automations`](../../automations.md) - [`cameras`](../../cameras/README.md) - - [`conditions`](../../conditions.md) + - [`conditions` / `triggers`](../../conditions-triggers.md) - [`dimensions`](../../dimensions.md) - [`elements`](../../elements/README.md) - [`folders`](../../folders.md) diff --git a/docs/configuration/actions/stock/README.md b/docs/configuration/actions/stock/README.md index 71a7b646..ef452449 100644 --- a/docs/configuration/actions/stock/README.md +++ b/docs/configuration/actions/stock/README.md @@ -1,5 +1,33 @@ # 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` 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: action: fire-dom-event 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 ``` diff --git a/docs/configuration/actions/stock/_sidebar.md b/docs/configuration/actions/stock/_sidebar.md index e1bfea59..a4c1b1c7 100644 --- a/docs/configuration/actions/stock/_sidebar.md +++ b/docs/configuration/actions/stock/_sidebar.md @@ -5,7 +5,7 @@ - [Stock Actions](README.md) - [`automations`](../../automations.md) - [`cameras`](../../cameras/README.md) - - [`conditions`](../../conditions.md) + - [`conditions` / `triggers`](../../conditions-triggers.md) - [`dimensions`](../../dimensions.md) - [`elements`](../../elements/README.md) - [`folders`](../../folders.md) diff --git a/docs/configuration/automations.md b/docs/configuration/automations.md index 79143258..b02d825d 100644 --- a/docs/configuration/automations.md +++ b/docs/configuration/automations.md @@ -1,25 +1,36 @@ # `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] -> 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 automations: - - conditions: + - triggers: + - [trigger] + conditions: - [condition] actions: - [action] - actions_not: - - [action] ``` -| Option | Default | Description | -| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ | -| `conditions` | | A list of [conditions](conditions.md) that must evaluate to `true` in order to trigger the automation. | -| `actions` | | An optional list of [actions](actions/README.md) that will be run when the [conditions](conditions.md) evaluate `true`. | -| `actions_not` | | An optional list of [actions](actions/README.md) that will be run when the [conditions](conditions.md) evaluate `false`. | +| Option | Default | Description | +| ------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `triggers` | | A list of [triggers](conditions-triggers.md) that initiate the automation. At least one is required. | +| `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` | | A list of [actions](actions/README.md) run when a trigger fires and the conditions hold (or no conditions are configured). | # Fully expanded reference @@ -27,13 +38,14 @@ automations: ```yaml automations: - - conditions: + - triggers: + - trigger: state + entity_id: binary_sensor.front_door + to: 'on' + conditions: - condition: fullscreen fullscreen: true actions: - action: custom:advanced-camera-card-action advanced_camera_card_action: substream_on - actions_not: - - action: custom:advanced-camera-card-action - advanced_camera_card_action: substream_off ``` diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index 4fb4df4d..96bd30d9 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -16,24 +16,24 @@ cameras_global: # [...] ``` -| 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`. | -| `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). | -| `cast` | | Configuration that controls how this camera is "casted" / sent to media players. See [`cast`](#cast). | -| `dependencies` | | Other cameras that this camera should depend upon. See [`dependencies`](#dependencies). | -| `dimensions` | | Controls the dimensions and layout for media from this camera. See [`dimensions`](#dimensions). | -| `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). | -| `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. | -| `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). | -| `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). | -| `title` | Autodetected from `camera_entity` if that is specified. | A friendly name for this camera to use in the card. | -| `triggers` | | Define what should cause this camera to update/trigger. See [`triggers`](#triggers). | -| `webrtc_card` | | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See [Live Provider](live-provider.md?id=webrtc_card). | +| 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`. | +| `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). | +| `cast` | | Configuration that controls how this camera is "casted" / sent to media players. See [`cast`](#cast). | +| `dependencies` | | Other cameras that this camera should depend upon. See [`dependencies`](#dependencies). | +| `dimensions` | | Controls the dimensions and layout for media from this camera. See [`dimensions`](#dimensions). | +| `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). | +| `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-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). | +| `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). | +| `title` | Autodetected from `camera_entity` if that is specified. | A friendly name for this camera to use in the card. | +| `triggers` | | Define what should cause this camera to update/trigger. See [`triggers`](#triggers). | +| `webrtc_card` | | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See [Live Provider](live-provider.md?id=webrtc_card). | ## `capabilities` @@ -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 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 cameras: - camera_entity: camera.office diff --git a/docs/configuration/cameras/_sidebar.md b/docs/configuration/cameras/_sidebar.md index 4e05ccdd..cb934b9f 100644 --- a/docs/configuration/cameras/_sidebar.md +++ b/docs/configuration/cameras/_sidebar.md @@ -5,7 +5,7 @@ - [`cameras`](README.md) - [`live_provider`](live-provider.md) - [`engine`](engine.md) - - [`conditions`](../conditions.md) + - [`conditions` / `triggers`](../conditions-triggers.md) - [`dimensions`](../dimensions.md) - [`folders`](../folders.md) - [`elements`](../elements/README.md) diff --git a/docs/configuration/conditions-triggers.md b/docs/configuration/conditions-triggers.md new file mode 100644 index 00000000..6cae23fc --- /dev/null +++ b/docs/configuration/conditions-triggers.md @@ -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 + +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 +``` diff --git a/docs/configuration/conditions.md b/docs/configuration/conditions.md deleted file mode 100644 index 9304ebd3..00000000 --- a/docs/configuration/conditions.md +++ /dev/null @@ -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 -``` diff --git a/docs/configuration/elements/_sidebar.md b/docs/configuration/elements/_sidebar.md index bab3145f..5374459a 100644 --- a/docs/configuration/elements/_sidebar.md +++ b/docs/configuration/elements/_sidebar.md @@ -3,7 +3,7 @@ - [`actions`](../actions/README.md) - [`automations`](../automations.md) - [`cameras`](../cameras/README.md) - - [`conditions`](../conditions.md) + - [`conditions` / `triggers`](../conditions-triggers.md) - [`dimensions`](../dimensions.md) - [`elements`](README.md) - [Custom Elements](./custom/README.md) diff --git a/docs/configuration/elements/custom/README.md b/docs/configuration/elements/custom/README.md index d6ceb7f9..6f6e7603 100644 --- a/docs/configuration/elements/custom/README.md +++ b/docs/configuration/elements/custom/README.md @@ -2,7 +2,7 @@ ## `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 elements: @@ -12,11 +12,11 @@ elements: Parameters for the `custom:advanced-camera-card-conditional` element: -| Parameter | Description | -| ------------ | ---------------------------------------------------------------------------------------------------------------- | -| `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. | -| `elements` | The elements to render. Can be any supported element. | +| Parameter | Description | +| ------------ | ------------------------------------------------------------------------------------------------------------------------- | +| `type` | Must be `custom:advanced-camera-card-conditional`. | +| `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. | See the [conditional elements example](../../../examples.md?id=conditional-elements). diff --git a/docs/configuration/elements/custom/_sidebar.md b/docs/configuration/elements/custom/_sidebar.md index f9f848dc..23048ae2 100644 --- a/docs/configuration/elements/custom/_sidebar.md +++ b/docs/configuration/elements/custom/_sidebar.md @@ -3,7 +3,7 @@ - [`actions`](../README.md) - [`automations`](../../automations.md) - [`cameras`](../../cameras/README.md) - - [`conditions`](../../conditions.md) + - [`conditions` / `triggers`](../../conditions-triggers.md) - [`dimensions`](../../dimensions.md) - [`elements`](../../elements/README.md) - [Custom Actions](README.md) diff --git a/docs/configuration/elements/stock/_sidebar.md b/docs/configuration/elements/stock/_sidebar.md index c522bd65..66c6c292 100644 --- a/docs/configuration/elements/stock/_sidebar.md +++ b/docs/configuration/elements/stock/_sidebar.md @@ -3,7 +3,7 @@ - [`actions`](../README.md) - [`automations`](../../automations.md) - [`cameras`](../../cameras/README.md) - - [`conditions`](../../conditions.md) + - [`conditions` / `triggers`](../../conditions-triggers.md) - [`dimensions`](../../dimensions.md) - [`elements`](../../elements/README.md) - [Custom Actions](../custom/README.md) diff --git a/docs/configuration/overrides.md b/docs/configuration/overrides.md index 4a67b05d..35a0cbaa 100644 --- a/docs/configuration/overrides.md +++ b/docs/configuration/overrides.md @@ -1,6 +1,6 @@ # `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). ```yaml @@ -16,12 +16,12 @@ overrides: 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. -| Option | Default | Description | -| ------------ | ------- | ------------------------------------------------------------------------------------------------------------ | -| `conditions` | | A list of [conditions](conditions.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). | -| `merge` | | A dictionary of configuration paths to merge. See [`merge`](#merge). | -| `set` | | A dictionary of configuration paths to set. See [`set`](#set). | +| Option | Default | Description | +| ------------ | ------- | --------------------------------------------------------------------------------------------------------------------- | +| `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). | +| `merge` | | A dictionary of configuration paths to merge. See [`merge`](#merge). | +| `set` | | A dictionary of configuration paths to set. See [`set`](#set). | ## Configuration Paths diff --git a/docs/configuration/templates.md b/docs/configuration/templates.md index 1c88f178..a7df11e5 100644 --- a/docs/configuration/templates.md +++ b/docs/configuration/templates.md @@ -7,6 +7,12 @@ Advanced Camera Card data, to be accessible. Templates may be used in: - [Actions / Automations](./actions/README.md) - [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 The Advanced Camera Card uses @@ -19,13 +25,13 @@ accesses Home Assistant state. ## Custom Templates -Custom template values must be proceeded by `advanced_camera_card` (or `acc` for -short). +Custom template values must be prefixed with `acc`. | Template | Replaced with | | -------- | ------------------------------------------------- | | `camera` | The currently selected camera. | | `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 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 additional `media` variable is available with these properties: -Media template values must be proceeded by `advanced_camera_card.media` (or -`acc.media` for short). +Media template values must be prefixed with `acc.media`. | Template | Replaced with | | ----------- | --------------------------------------------------------------------------------- | @@ -45,34 +50,35 @@ Media template values must be proceeded by `advanced_camera_card.media` (or ### Triggers -If the action is called by an [Advanced Camera Card -Automation](./automations.md), additional data is available representing the -current and prior state of whatever triggered the action. +When an action runs from an [automation](./automations.md), a top-level +`trigger` variable describes what fired it (as in native Home Assistant +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 -`acc.trigger` for short). +The stock `state` and `numeric_state` [triggers](./conditions-triggers.md) carry +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 | -| -------------- | ------------------------------------------------------------------------------------------------ | -| `camera.to` | For [camera conditions](./conditions.md?id=camera), the currently selected camera. | -| `camera.from` | For [camera conditions](./conditions.md?id=camera), the previously selected camera. | -| `view.to` | For [view conditions](./conditions.md?id=view), the currently selected view. | -| `view.from` | For [view conditions](./conditions.md?id=view), the previously selected view. | -| `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. | +| Template | Replaced with | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `trigger.platform` | The trigger platform (`state` or `numeric_state`). | +| `trigger.entity_id` | The entity that triggered (also available as `trigger.entity`). | +| `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`. | +| `trigger.to_state` | The full Home Assistant state object after the change, e.g. `trigger.to_state.state` or `trigger.to_state.attributes.`. | -> [!NOTE] -> If an action is triggered with multiple [state -> conditions](./conditions.md?id=state), only data from the last listed state -> condition is available. +The card-specific [triggers](./conditions-triggers.md) (e.g. `camera`, `view`, `config`) +carry the card state before and after the change: -> [!NOTE] -> If you use an [`or`](./conditions.md?id=or) condition, only the trigger data -> for the first matching trigger will be included. - -Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if -you need data from additional conditions. +| Template | Replaced with | +| ------------------ | ------------------------------------------------------------------------------------------------------ | +| `trigger.platform` | `acc` for card-specific triggers. | +| `trigger.type` | The card trigger kind (e.g. `camera`, `view`, `config`). | +| `trigger.from_acc` | The card state before the change, with `camera`, `view` and `config` (e.g. `trigger.from_acc.camera`). | +| `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 trigger state. diff --git a/docs/configuration/view.md b/docs/configuration/view.md index 6c28b352..7ef394e5 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -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). | | `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). | -| `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). | | `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)). | @@ -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`. > [!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 diff --git a/docs/examples.md b/docs/examples.md index 5ea866f9..0491f1ae 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -47,7 +47,7 @@ fullscreen mode. > 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 > 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 type: custom:advanced-camera-card @@ -72,13 +72,18 @@ cameras: # Optionally allow PTZ controls on the substream. - ptz automations: - - conditions: - - condition: fullscreen + # Entering fullscreen: turn the substream on. + - triggers: + - trigger: fullscreen fullscreen: true actions: - action: custom:advanced-camera-card-action 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 advanced_camera_card_action: substream_off ``` @@ -121,8 +126,8 @@ elements: - action: custom:advanced-camera-card-action advanced_camera_card_action: fullscreen automations: - - conditions: - - condition: fullscreen + - triggers: + - trigger: fullscreen fullscreen: false actions: - action: custom:advanced-camera-card-action @@ -134,7 +139,7 @@ automations: ### 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 @@ -142,8 +147,8 @@ In this example, the view will change to `live`, when `Alt+Z` is pressed, and ch ```yaml automations: - - conditions: - - condition: key + - triggers: + - trigger: key key: z alt: true actions: @@ -163,14 +168,22 @@ In this example, the view will change to `live`, when `Alt+Z` is _held_ down, an ```yaml automations: - - conditions: - - condition: key + # Held down: switch to live. + - triggers: + - trigger: key key: z alt: true + state: down actions: - action: custom:advanced-camera-card-action 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 advanced_camera_card_action: clips ``` @@ -708,15 +721,20 @@ cameras: # Also allow PTZ controls on the substream. - ptz automations: - - actions: + # On interaction: turn the HD substream on. + - triggers: + - trigger: interaction + interaction: true + actions: - action: custom:advanced-camera-card-action 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 advanced_camera_card_action: substream_off - conditions: - - condition: interaction - interaction: true ``` ## Media layout @@ -880,7 +898,7 @@ elements: ## 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 @@ -1165,8 +1183,9 @@ type: custom:advanced-camera-card cameras: - camera_entity: camera.office automations: - - conditions: - - condition: triggered + # Camera triggered: show the alert. + - triggers: + - trigger: triggered triggered: - camera.office actions: @@ -1182,7 +1201,11 @@ automations: expand: true exclusive: true sufficient: true - actions_not: + # No longer triggered: reset the status bar. + - triggers: + - trigger: triggered + triggered: [] + actions: - action: custom:advanced-camera-card-action advanced_camera_card_action: status_bar status_bar_action: reset @@ -1368,8 +1391,8 @@ tap_action: action: perform-action perform_action: homeassistant.service data: - camera: '{{ advanced_camera_card.camera }}' - view: '{{ advanced_camera_card.view }}' + camera: '{{ acc.camera }}' + view: '{{ acc.view }}' ``` 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 automations: - - conditions: - - condition: camera + - triggers: + - trigger: camera actions: - action: perform-action perform_action: homeassistant.service data: - from_camera: '{{ acc.trigger.camera.from }}' - to_camera: '{{ acc.trigger.camera.to }}' + from_camera: '{{ trigger.from_acc.camera }}' + to_camera: '{{ trigger.to_acc.camera }}' ``` See [Trigger Templates](./configuration/templates.md?id=triggers). @@ -1685,10 +1708,11 @@ cameras: debug: logging: true automations: - - conditions: - - condition: state - entity: binary_sensor.door_contact - state: 'on' + # Door opens: zoom in. + - triggers: + - trigger: state + entity_id: binary_sensor.door_contact + to: 'on' actions: - action: custom:advanced-camera-card-action advanced_camera_card_action: ptz_digital @@ -1698,7 +1722,12 @@ automations: pan: x: 38 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 advanced_camera_card_action: ptz_digital target_id: camera.living_room diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fa5e38cf..a8f60578 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -43,6 +43,23 @@ To upgrade: If the automatic upgrade button is not visible, your configuration may already 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 not loading? Permanent "loading circle"? diff --git a/docs/usage/2-way-audio.md b/docs/usage/2-way-audio.md index 895584b9..dcc0a18d 100644 --- a/docs/usage/2-way-audio.md +++ b/docs/usage/2-way-audio.md @@ -79,7 +79,7 @@ Calls can also be controlled programmatically with the [`call_answer`](../configuration/actions/custom/README.md?id=call_answer), and [`call_end`](../configuration/actions/custom/README.md?id=call_end) actions -- 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. ### Call lifecycle diff --git a/src/camera-manager/frigate/watcher.ts b/src/camera-manager/frigate/watcher.ts index 3433e5e5..e957eea9 100644 --- a/src/camera-manager/frigate/watcher.ts +++ b/src/camera-manager/frigate/watcher.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { HomeAssistant } from '../../ha/types'; -import { KeyedSubscriptionManager } from '../../utils/keyed-subscription-manager'; +import { KeyedSubscriptionManager } from '../../utils/concurrency/keyed-subscription-manager'; import { FrigateEventChange, FrigateReviewChange, diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 3889907c..cfc87b70 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -203,7 +203,8 @@ export class CameraManager { const engine = engineType ? engines.get(engineType) ?? (await this._engineFactory.createEngine(engineType, { - eventCallback: (ev) => this._api.getTriggersManager().handleCameraEvent(ev), + eventCallback: (ev) => + this._api.getCameraTriggersManager().handleCameraEvent(ev), stateWatcher: this._api.getHASSManager().getStateWatcher(), eventWatcher: this._api.getHASSManager().getEventWatcher(), resolvedMediaCache: this._api.getResolvedMediaCache(), diff --git a/src/card-controller/actions/actions-manager.ts b/src/card-controller/actions/actions-manager.ts index eb1a80c8..e0ab5fc7 100644 --- a/src/card-controller/actions/actions-manager.ts +++ b/src/card-controller/actions/actions-manager.ts @@ -1,5 +1,6 @@ import { ActionContext } from 'action'; import { z } from 'zod'; +import { TriggerData } from '../../condition-trigger/triggers/types.js'; import { ActionConfig, Actions, @@ -14,7 +15,11 @@ import { allPromises, errorToConsole } from '../../utils/basic.js'; import { TemplateRenderer } from '../templates/index.js'; import { CardActionsManagerAPI } from '../types.js'; import { ActionSet } from './actions/set.js'; -import { ActionsExecutionRequest, ActionsExecutor } from './types.js'; +import { + ActionPrepareCallback, + ActionsExecutionRequest, + ActionsExecutor, +} from './types.js'; const INTERACTIONS = ['tap', 'double_tap', 'hold', 'start_tap', 'end_tap'] as const; export type InteractionName = (typeof INTERACTIONS)[number]; @@ -138,23 +143,31 @@ export class ActionsManager implements ActionsExecutor { request: ActionsExecutionRequest, renderTemplates = true, ): Promise { - const hass = this._api.getHASSManager().getHASS(); - const renderedAction: ActionConfig | ActionConfig[] = - renderTemplates && hass && this._templateRenderer - ? (this._templateRenderer.renderRecursively(hass, request.actions, { - conditionState: this._api.getConditionStateManager().getState(), - triggerData: request?.triggerData, - }) as ActionConfig | ActionConfig[]) - : request.actions; - - const allowedActions = this._api.getLockManager().getAllowedActions(renderedAction); + // Lock filtering and the factory both classify on the raw (unrendered) + // discriminator (`action` / `advanced_camera_card_action`). A templated + // `advanced_camera_card_action` (permitted only by the loose custom-action + // schema) is left unresolved, matches no action type, and is dropped. + const allowedActions = this._api.getLockManager().getAllowedActions(request.actions); if (!allowedActions.length) { return; } + // Each action prepares itself (via Action.prepare) just before it runs, so + // it observes what an earlier action may have changed. Skip when the caller + // opts out (the templates are already rendered) or there is no renderer. + const renderer = this._templateRenderer; + const actionPrepareCallback = + renderTemplates && renderer + ? this._createActionPrepareCallback(renderer, request.triggerData) + : undefined; + const actionSet = new ActionSet(this._actionContext, allowedActions, { - config: request.config, - cardID: this._api.getConfigManager().getConfig()?.card_id, + factoryOptions: { + config: request.config, + cardID: this._api.getConfigManager().getConfig()?.card_id, + triggerData: request?.triggerData, + }, + actionPrepareCallback, }); this._actionsInFlight.push(actionSet); @@ -168,4 +181,22 @@ export class ActionsManager implements ActionsExecutor { } this._actionsInFlight = this._actionsInFlight.filter((a) => a !== actionSet); } + + private _createActionPrepareCallback( + renderer: TemplateRenderer, + triggerData?: TriggerData, + ): ActionPrepareCallback { + // Render against the state (incl. HASS) as it is *when the action runs* -- + // fixed trigger context, fresh card/HASS state per step. The one cast lives + // here as renderRecursively returns `unknown`. + return (value: T): T => { + const hass = this._api.getHASSManager().getHASS(); + return hass + ? (renderer.renderRecursively(hass, value, { + conditionState: this._api.getConditionStateManager().getState(), + triggerData, + }) as T) + : value; + }; + } } diff --git a/src/card-controller/actions/actions/base.ts b/src/card-controller/actions/actions/base.ts index e6c4c38b..d40bbbc3 100644 --- a/src/card-controller/actions/actions/base.ts +++ b/src/card-controller/actions/actions/base.ts @@ -4,24 +4,35 @@ import { AuxillaryActionConfig, } from '../../../config/schema/actions/types.js'; import { localize } from '../../../localize/localize.js'; -import { isAdvancedCameraCardCustomAction } from '../../../utils/action'; +import { getActionName } from '../../../utils/action'; import { CardActionsAPI } from '../../types'; -import { Action, ActionAbortError } from '../types'; +import { Action, ActionAbortError, ActionPrepareCallback } from '../types'; export class BaseAction implements Action { protected _context: ActionContext; - protected _action: T; + protected _rawAction: T; + protected _preparedAction: T | null = null; protected _config?: AuxillaryActionConfig; constructor(context: ActionContext, action: T, config?: AuxillaryActionConfig) { this._context = context; - this._action = action; + this._rawAction = action; this._config = config; } + public prepare(actionPrepareCallback: ActionPrepareCallback): void { + this._preparedAction = actionPrepareCallback(this._rawAction); + } + + // The config to act on: the prepared (rendered) form once prepare() has run, + // otherwise the raw config (e.g. under direct execution without a prepare). + protected _getAction(): T { + return this._preparedAction ?? this._rawAction; + } + protected _shouldSeekConfirmation(api: CardActionsAPI): boolean { const hass = api.getHASSManager().getHASS(); - const action: ActionConfig = this._action; + const action: ActionConfig = this._getAction(); return ( (typeof action.confirmation === 'boolean' && action.confirmation) || @@ -33,14 +44,10 @@ export class BaseAction implements Action { public async execute(api: CardActionsAPI): Promise { if (this._shouldSeekConfirmation(api)) { - const action: ActionConfig = this._action; - const baseAction = action.action; - const actionName = isAdvancedCameraCardCustomAction(action) - ? action.advanced_camera_card_action - : baseAction; + const action: ActionConfig = this._getAction(); const text = (typeof action.confirmation === 'object' ? action.confirmation.text : null) ?? - `${localize('actions.confirmation')}: ${actionName}`; + `${localize('actions.confirmation')}: ${getActionName(action)}`; if (!confirm(text)) { throw new ActionAbortError(localize('actions.abort')); } diff --git a/src/card-controller/actions/actions/call-service.ts b/src/card-controller/actions/actions/call-service.ts index d3ee3b40..53af0291 100644 --- a/src/card-controller/actions/actions/call-service.ts +++ b/src/card-controller/actions/actions/call-service.ts @@ -11,7 +11,8 @@ export class CallServiceAction extends AdvancedCameraCardAction { await super.execute(api); + const action = this._getAction(); await api.getCallManager().start({ - cameraID: this._action.camera, - streamID: this._action.stream, + cameraID: action.camera, + streamID: action.stream, }); } } diff --git a/src/card-controller/actions/actions/camera-select.ts b/src/card-controller/actions/actions/camera-select.ts index 1cc3afa4..b42c93b9 100644 --- a/src/card-controller/actions/actions/camera-select.ts +++ b/src/card-controller/actions/actions/camera-select.ts @@ -6,10 +6,11 @@ export class CameraSelectAction extends AdvancedCameraCardAction { await super.execute(api); + const action = this._getAction(); const selectCameraID = - this._action.camera ?? - (this._action.triggered - ? api.getTriggersManager().getMostRecentlyTriggeredCameraID() + action.camera ?? + (action.triggered + ? api.getCameraTriggersManager().getMostRecentlyTriggeredCameraID() : null); const view = api.getViewManager().getView(); const config = api.getConfigManager().getConfig(); diff --git a/src/card-controller/actions/actions/custom.ts b/src/card-controller/actions/actions/custom.ts index 75f1871e..8ba8aaf9 100644 --- a/src/card-controller/actions/actions/custom.ts +++ b/src/card-controller/actions/actions/custom.ts @@ -7,6 +7,10 @@ export class CustomAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { await super.execute(api); - fireHASSEvent(api.getCardElementManager().getElement(), 'll-custom', this._action); + fireHASSEvent( + api.getCardElementManager().getElement(), + 'll-custom', + this._getAction(), + ); } } diff --git a/src/card-controller/actions/actions/display-mode-select.ts b/src/card-controller/actions/actions/display-mode-select.ts index 0d094b82..502b110a 100644 --- a/src/card-controller/actions/actions/display-mode-select.ts +++ b/src/card-controller/actions/actions/display-mode-select.ts @@ -8,7 +8,7 @@ export class DisplayModeSelectAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { await super.execute(api); - switch (this._action.effect_action) { + const action = this._getAction(); + switch (action.effect_action) { case 'start': - api.getEffectsManager().startEffect(this._action.effect); + api.getEffectsManager().startEffect(action.effect); break; case 'stop': - api.getEffectsManager().stopEffect(this._action.effect); + api.getEffectsManager().stopEffect(action.effect); break; case 'toggle': - api.getEffectsManager().toggleEffect(this._action.effect); + api.getEffectsManager().toggleEffect(action.effect); break; } } diff --git a/src/card-controller/actions/actions/if.ts b/src/card-controller/actions/actions/if.ts new file mode 100644 index 00000000..8afaf042 --- /dev/null +++ b/src/card-controller/actions/actions/if.ts @@ -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 { + 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 { + 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, + }); + } +} diff --git a/src/card-controller/actions/actions/internal-callback.ts b/src/card-controller/actions/actions/internal-callback.ts index a13f87a3..abd1dd10 100644 --- a/src/card-controller/actions/actions/internal-callback.ts +++ b/src/card-controller/actions/actions/internal-callback.ts @@ -6,6 +6,6 @@ export class InternalCallbackAction extends AdvancedCameraCardAction { await super.execute(api); - await this._action.callback(api); + await this._getAction().callback(api); } } diff --git a/src/card-controller/actions/actions/log.ts b/src/card-controller/actions/actions/log.ts index 0f42f626..3a8d3db5 100644 --- a/src/card-controller/actions/actions/log.ts +++ b/src/card-controller/actions/actions/log.ts @@ -6,6 +6,7 @@ export class LogAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { await super.execute(api); - console[this._action.level](this._action.message); + const action = this._getAction(); + console[action.level](action.message); } } diff --git a/src/card-controller/actions/actions/media-player.ts b/src/card-controller/actions/actions/media-player.ts index d34e0cdd..7836ef20 100644 --- a/src/card-controller/actions/actions/media-player.ts +++ b/src/card-controller/actions/actions/media-player.ts @@ -8,10 +8,11 @@ export class MediaPlayerAction extends AdvancedCameraCardAction { await super.execute(api); - const mediaPlayer = this._action.media_player; + const action = this._getAction(); + const mediaPlayer = action.media_player; const mediaPlayerController = api.getMediaPlayerManager(); - if (this._action.media_player_action === 'stop') { + if (action.media_player_action === 'stop') { await mediaPlayerController.stop(mediaPlayer); return; } diff --git a/src/card-controller/actions/actions/more-info.ts b/src/card-controller/actions/actions/more-info.ts index 3145626b..25fd5f43 100644 --- a/src/card-controller/actions/actions/more-info.ts +++ b/src/card-controller/actions/actions/more-info.ts @@ -7,7 +7,7 @@ export class MoreInfoAction extends AdvancedCameraCardAction { await super.execute(api); - const entityID = this._action.entity ?? this._config?.entity ?? null; + const entityID = this._getAction().entity ?? this._config?.entity ?? null; if (!entityID) { return; } diff --git a/src/card-controller/actions/actions/navigate.ts b/src/card-controller/actions/actions/navigate.ts index ac9649af..42826526 100644 --- a/src/card-controller/actions/actions/navigate.ts +++ b/src/card-controller/actions/actions/navigate.ts @@ -7,13 +7,14 @@ export class NavigateAction extends AdvancedCameraCardAction { await super.execute(api); - if (!!this._action.navigation_replace) { - history.replaceState(null, '', this._action.navigation_path); + const action = this._getAction(); + if (!!action.navigation_replace) { + history.replaceState(null, '', action.navigation_path); } else { - history.pushState(null, '', this._action.navigation_path); + history.pushState(null, '', action.navigation_path); } fireHASSEvent(window, 'location-changed', { - replace: !!this._action.navigation_replace, + replace: !!action.navigation_replace, }); } } diff --git a/src/card-controller/actions/actions/notification.ts b/src/card-controller/actions/actions/notification.ts index 41c30870..e11917af 100644 --- a/src/card-controller/actions/actions/notification.ts +++ b/src/card-controller/actions/actions/notification.ts @@ -5,6 +5,6 @@ import { AdvancedCameraCardAction } from './base'; export class NotificationAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { await super.execute(api); - api.getNotificationManager().setNotification(this._action.notification); + api.getNotificationManager().setNotification(this._getAction().notification); } } diff --git a/src/card-controller/actions/actions/perform-action.ts b/src/card-controller/actions/actions/perform-action.ts index cc784fa8..77fc3d53 100644 --- a/src/card-controller/actions/actions/perform-action.ts +++ b/src/card-controller/actions/actions/perform-action.ts @@ -11,7 +11,8 @@ export class PerformActionAction extends AdvancedCameraCardAction { await super.execute(api); + const action = this._getAction(); const currentEnabled = api.getViewManager().getView()?.context?.ptzControls?.enabled; // If `enabled` is explicit, use it. If only `type` is being changed, leave @@ -13,8 +14,8 @@ export class PTZControlsAction extends AdvancedCameraCardAction { await super.execute(api); + const action = this._getAction(); const view = api.getViewManager().getView(); if (!view) { return; } const targetID = - this._action.target_id ?? + action.target_id ?? getPTZTarget(view, { type: 'digital', cameraManager: api.getCameraManager() }) ?.targetID; if (!targetID) { return; } - if (!!this._action.absolute || !this._action.ptz_phase) { + if (!!action.absolute || !action.ptz_phase) { return await this._stepChange(api, targetID); } /* istanbul ignore else: the else path cannot be reached -- @preserve */ - if (this._action.ptz_phase === 'start') { + if (action.ptz_phase === 'start') { await stopInProgressForThisTarget(targetID, this._context.ptzDigital); setInProgressForThisTarget(targetID, this._context, 'ptzDigital', this); @@ -74,21 +75,22 @@ export class PTZDigitalAction extends AdvancedCameraCardAction this._stepChange(api, targetID), ); - } else if (this._action.ptz_phase === 'stop') { + } else if (action.ptz_phase === 'stop') { await stopInProgressForThisTarget(targetID, this._context.ptzDigital); delete this._context.ptzDigital?.[targetID]; } } private _convertActionToZoomSettings(base?: PartialZoomSettings): PartialZoomSettings { - if (!this._action.absolute && !this._action.ptz_action) { + const action = this._getAction(); + if (!action.absolute && !action.ptz_action) { // If neither an absolute position nor an action are specified, the request // is assumed to be to return to default. return {}; } - if (this._action.absolute) { - return this._action.absolute; + if (action.absolute) { + return action.absolute; } const zoom = base?.zoom ?? ZOOM_DEFAULT_SCALE; @@ -98,21 +100,21 @@ export class PTZDigitalAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { await super.execute(api); + const action = this._getAction(); + const view = api.getViewManager().getView(); if (!view) { return; } const ptzCameraID = - this._action.camera ?? + action.camera ?? getPTZTarget(view, { type: 'ptz', cameraManager: api.getCameraManager() }) ?.targetID ?? null; @@ -53,34 +55,34 @@ export class PTZAction extends AdvancedCameraCardAction { return; } - if (!this._action.ptz_action) { + if (!action.ptz_action) { if (ptzCapabilities.presets && ptzCapabilities.presets.length >= 1) { await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', { - phase: this._action.ptz_phase, + phase: action.ptz_phase, preset: ptzCapabilities.presets[0], }); } return; } - const capabilityKey = ptzActionToCapabilityKey(this._action.ptz_action); + const capabilityKey = ptzActionToCapabilityKey(action.ptz_action); if ( (capabilityKey && ptzCapabilities[capabilityKey]?.includes( - this._action.ptz_phase ? PTZMovementType.Continuous : PTZMovementType.Relative, + action.ptz_phase ? PTZMovementType.Continuous : PTZMovementType.Relative, )) || - this._action.ptz_action === 'preset' + action.ptz_action === 'preset' ) { // Scenario: Camera natively supports requested move type. return await api .getCameraManager() - .executePTZAction(ptzCameraID, this._action.ptz_action, { - phase: this._action.ptz_phase, - preset: this._action.ptz_preset, + .executePTZAction(ptzCameraID, action.ptz_action, { + phase: action.ptz_phase, + preset: action.ptz_preset, }); } - if (this._action.ptz_phase === 'start') { + if (action.ptz_phase === 'start') { // Scenario: Asked to start a continuous move, camera only supports relative moves natively. await stopInProgressForThisTarget(ptzCameraID, this._context.ptz); setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this); @@ -88,12 +90,10 @@ export class PTZAction extends AdvancedCameraCardAction { const singleStep = async (): Promise => { /* istanbul ignore else: the else path cannot be reached as ptz_action being present is checked above -- @preserve */ - if (this._action.ptz_action) { - await api - .getCameraManager() - .executePTZAction(ptzCameraID, this._action.ptz_action, { - preset: this._action.ptz_preset, - }); + if (action.ptz_action) { + await api.getCameraManager().executePTZAction(ptzCameraID, action.ptz_action, { + preset: action.ptz_preset, + }); } if (!this._stopped) { @@ -109,30 +109,26 @@ export class PTZAction extends AdvancedCameraCardAction { this._stopped = false; await singleStep(); - } else if (this._action.ptz_phase === 'stop') { + } else if (action.ptz_phase === 'stop') { // Scenario: Asked to stop continuous move, camera only supports relative moves natively. await stopInProgressForThisTarget(ptzCameraID, this._context.ptz); } else { this._stopped = false; // Relative move (but camera only supports continuous). - await api - .getCameraManager() - .executePTZAction(ptzCameraID, this._action.ptz_action, { - preset: this._action.ptz_preset, - phase: 'start', - }); + await api.getCameraManager().executePTZAction(ptzCameraID, action.ptz_action, { + preset: action.ptz_preset, + phase: 'start', + }); this._timer.start(ptzConfiguration.c2r_delay_between_calls_seconds, async () => { /* istanbul ignore else: the else path cannot be reached as ptz_action being present is checked above -- @preserve */ - if (this._action.ptz_action) { - await api - .getCameraManager() - .executePTZAction(ptzCameraID, this._action.ptz_action, { - preset: this._action.ptz_preset, - phase: 'stop', - }); + if (action.ptz_action) { + await api.getCameraManager().executePTZAction(ptzCameraID, action.ptz_action, { + preset: action.ptz_preset, + phase: 'stop', + }); } }); } diff --git a/src/card-controller/actions/actions/set-review.ts b/src/card-controller/actions/actions/set-review.ts index b8f84af1..2b35fc74 100644 --- a/src/card-controller/actions/actions/set-review.ts +++ b/src/card-controller/actions/actions/set-review.ts @@ -18,7 +18,7 @@ export class SetReviewAction extends AdvancedCameraCardAction { @@ -37,7 +35,20 @@ export class ActionSet implements Action { break; } - await action.execute(api); + const concreteAction = this._factory.createAction( + this._context, + action, + this._factoryOptions, + ); + if (concreteAction) { + // Prepare against the state as it is now, so an action observes what an + // earlier action in the sequence changed. A prepare error aborts the + // rest of the sequence (it propagates to the caller's handler). + if (this._actionPrepareCallback) { + concreteAction.prepare(this._actionPrepareCallback); + } + await concreteAction.execute(api); + } } } diff --git a/src/card-controller/actions/actions/sleep.ts b/src/card-controller/actions/actions/sleep.ts index 05611006..278c2707 100644 --- a/src/card-controller/actions/actions/sleep.ts +++ b/src/card-controller/actions/actions/sleep.ts @@ -8,6 +8,6 @@ export class SleepAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { await super.execute(api); - await sleep(timeDeltaToSeconds(this._action.duration)); + await sleep(timeDeltaToSeconds(this._getAction().duration)); } } diff --git a/src/card-controller/actions/actions/status-bar.ts b/src/card-controller/actions/actions/status-bar.ts index def81c77..d1ce21f4 100644 --- a/src/card-controller/actions/actions/status-bar.ts +++ b/src/card-controller/actions/actions/status-bar.ts @@ -6,17 +6,18 @@ export class StatusBarAction extends AdvancedCameraCardAction { await super.execute(api); - switch (this._action.status_bar_action) { + const action = this._getAction(); + switch (action.status_bar_action) { case 'reset': api.getStatusBarItemManager().removeAllDynamicStatusBarItems(); break; case 'add': - this._action.items?.forEach((item) => + action.items?.forEach((item) => api.getStatusBarItemManager().addDynamicStatusBarItem(item), ); break; case 'remove': - this._action.items?.forEach((item) => + action.items?.forEach((item) => api.getStatusBarItemManager().removeDynamicStatusBarItem(item), ); break; diff --git a/src/card-controller/actions/actions/substream-off.ts b/src/card-controller/actions/actions/substream-off.ts index bc9e2782..26491007 100644 --- a/src/card-controller/actions/actions/substream-off.ts +++ b/src/card-controller/actions/actions/substream-off.ts @@ -8,7 +8,7 @@ export class SubstreamOffAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { await super.execute(api); - window.open(this._action.url_path); + window.open(this._getAction().url_path); } } diff --git a/src/card-controller/actions/actions/view.ts b/src/card-controller/actions/actions/view.ts index 2146033d..c686eb0b 100644 --- a/src/card-controller/actions/actions/view.ts +++ b/src/card-controller/actions/actions/view.ts @@ -6,13 +6,14 @@ export class ViewAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { await super.execute(api); + const action = this._getAction(); await api.getViewManager().setViewByParametersWithNewQuery({ params: { - view: this._action.advanced_camera_card_action, + view: action.advanced_camera_card_action, }, - ...(this._action.folder && { + ...(action.folder && { queryExecutorOptions: { - folder: this._action.folder, + folder: action.folder, }, }), }); diff --git a/src/card-controller/actions/factory.ts b/src/card-controller/actions/factory.ts index 16250535..cbe63ade 100644 --- a/src/card-controller/actions/factory.ts +++ b/src/card-controller/actions/factory.ts @@ -1,7 +1,8 @@ import { ActionContext } from 'action'; +import { TriggerData } from '../../condition-trigger/triggers/types'; import { INTERNAL_CALLBACK_ACTION } from '../../config/schema/actions/custom/internal'; import { ActionConfig, AuxillaryActionConfig } from '../../config/schema/actions/types'; -import { isAdvancedCameraCardCustomAction } from '../../utils/action'; +import { isAdvancedCameraCardCustomAction, isIfAction } from '../../utils/action'; import { CallAnswerAction } from './actions/call-answer'; import { CallEndAction } from './actions/call-end'; import { CallServiceAction } from './actions/call-service'; @@ -15,6 +16,7 @@ import { DownloadAction } from './actions/download'; import { EffectAction } from './actions/effect'; import { ExpandAction } from './actions/expand'; import { FullscreenAction } from './actions/fullscreen'; +import { IfAction } from './actions/if'; import { InfoAction } from './actions/info'; import { InternalCallbackAction } from './actions/internal-callback'; import { LogAction } from './actions/log'; @@ -50,23 +52,36 @@ import { URLAction } from './actions/url'; import { ViewAction } from './actions/view'; import { Action } from './types'; +export interface ActionFactoryOptions { + config?: AuxillaryActionConfig; + cardID?: string; + + // The firing automation's trigger payload (if any), forwarded to actions with + // nested actions (e.g. `if`) so their branches can still resolve `trigger.*` + // templates when they render per-step. + triggerData?: TriggerData; +} + export class ActionFactory { public createAction( context: ActionContext, action: ActionConfig, - options?: { - config?: AuxillaryActionConfig; - cardID?: string; - }, + options?: ActionFactoryOptions, ): Action | null { if ( // Command not intended for this card (e.g. query string command). + // `card_id` is a static routing identifier, matched on the raw (template + // unrendered) config. action.card_id && action.card_id !== options?.cardID ) { return null; } + if (isIfAction(action)) { + return new IfAction(context, action, options?.config, options?.triggerData); + } + switch (action.action) { case 'more-info': return new MoreInfoAction(context, action, options?.config); @@ -182,11 +197,12 @@ export class ActionFactory { return new InternalCallbackAction(context, action, options?.config); } - /* istanbul ignore next: this path cannot be reached -- @preserve */ + // Reached when the discriminator is not a known action type -- e.g. a + // templated `advanced_camera_card_action`, which is classified on the raw + // (unrendered) action and so never matches a case. console.warn( `Advanced Camera Card received unknown card action: ${action['advanced_camera_card_action']}`, ); - /* istanbul ignore next: this path cannot be reached -- @preserve */ return null; } } diff --git a/src/card-controller/actions/types.ts b/src/card-controller/actions/types.ts index a070b673..deb66a91 100644 --- a/src/card-controller/actions/types.ts +++ b/src/card-controller/actions/types.ts @@ -1,4 +1,4 @@ -import { ConditionsTriggerData } from '../../conditions/types.js'; +import { TriggerData } from '../../condition-trigger/triggers/types.js'; import { ActionConfig, AuxillaryActionConfig, @@ -6,7 +6,18 @@ import { import { AdvancedCameraCardError } from '../../types.js'; import { CardActionsAPI } from '../types'; +// Renders a value's templates, returning a rendered copy. Generic so it +// preserves the value's type (the one cast lives in the renderer that supplies +// it). +export type ActionPrepareCallback = (value: T) => T; + export interface Action { + // Prepare this action for execution by rendering its templates against the + // current state. The rendered copy is stored separately; the original config + // is left intact, so the action stays reusable. Structural actions (`if`) + // override this to leave their nested action sequences raw, so those render + // per-step when their branch runs. + prepare(actionPrepareCallback: ActionPrepareCallback): void; execute(api: CardActionsAPI): Promise; stop(): Promise; } @@ -14,7 +25,7 @@ export interface Action { export interface ActionsExecutionRequest { actions: ActionConfig[] | ActionConfig; config?: AuxillaryActionConfig; - triggerData?: ConditionsTriggerData; + triggerData?: TriggerData; } export interface ActionsExecutor { diff --git a/src/card-controller/automations-manager.ts b/src/card-controller/automations-manager.ts index 033d7e27..38754d6f 100644 --- a/src/card-controller/automations-manager.ts +++ b/src/card-controller/automations-manager.ts @@ -1,7 +1,10 @@ -import { ConditionsManager } from '../conditions/conditions-manager.js'; -import { ConditionsEvaluationResult } from '../conditions/types.js'; +import { ConditionEvaluator } from '../condition-trigger/conditions/conditions/types.js'; +import { createConditionEvaluator } from '../condition-trigger/conditions/factory.js'; +import { TriggersManager } from '../condition-trigger/triggers/manager.js'; +import { TriggerData } from '../condition-trigger/triggers/types.js'; import { Automation, AutomationActions } from '../config/schema/automations.js'; import { localize } from '../localize/localize.js'; +import { TemplateRenderer } from './templates/index.js'; import { CardAutomationsAPI, TaggedAutomation } from './types.js'; const MAX_NESTED_AUTOMATION_EXECUTIONS = 10; @@ -9,7 +12,7 @@ const MAX_NESTED_AUTOMATION_EXECUTIONS = 10; export class AutomationsManager { private _api: CardAutomationsAPI; - private _automations = new Map(); + private _automations = new Map(); // A counter to avoid infinite loops, increases every time actions are run, // decreases every time actions are complete. @@ -20,28 +23,39 @@ export class AutomationsManager { } public deleteAutomations(tag?: unknown) { - for (const [automation, conditionManager] of this._automations) { + for (const [automation, triggers] of this._automations) { if (automation.tag === tag) { this._automations.delete(automation); - conditionManager.destroy(); + triggers.destroy(); } } } public addAutomations(automations: TaggedAutomation[]): void { + const context = { templateRenderer: new TemplateRenderer() }; for (const automation of automations) { - const conditionManager = new ConditionsManager( - automation.conditions, + const triggers = new TriggersManager( + automation.triggers, this._api.getConditionStateManager(), ); - conditionManager.addListener((result: ConditionsEvaluationResult) => - this._execute(automation, result), + + // The ongoing `conditions:` block is pull-evaluated at trigger time, so + // its evaluators are never subscribed and hold no resources to tear down. + // They live in the trigger callback and are released when `triggers` is + // destroyed. + const conditions = (automation.conditions ?? []).map((condition) => + createConditionEvaluator(condition, context), ); - this._automations.set(automation, conditionManager); + triggers.addListener((data) => this._execute(automation, conditions, data)); + this._automations.set(automation, triggers); } } - private _execute(automation: Automation, result: ConditionsEvaluationResult): void { + private _execute( + automation: Automation, + conditions: ConditionEvaluator[], + triggerData: TriggerData, + ): void { if ( !this._api.getHASSManager().hasHASS() || // Never execute automations if the card hasn't finished initializing, as @@ -55,17 +69,25 @@ export class AutomationsManager { return; } - const shouldExecute = result.result; - const actions = shouldExecute ? automation.actions : automation.actions_not; + // Evaluate the ongoing conditions against the current state at the instant + // the automation is triggered. The state manager updates its stored state + // before dispatching to listeners, so this already reflects the triggering + // change. + const state = this._api.getConditionStateManager().getState(); + const ongoingConditionsHold = conditions.every( + (evaluator) => evaluator.evaluate(state).result, + ); - if (!actions?.length) { + if (!ongoingConditionsHold || !automation.actions.length) { return; } const runActions = async (actions: AutomationActions): Promise => { - ++this._nestedAutomationExecutions; - - if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) { + // Check the limit *before* incrementing, so the overflow path holds no + // increment to leak; the `finally` then guarantees the decrement even if + // executing the actions throws. Either leak would permanently inflate the + // counter and eventually block all automations. + if (this._nestedAutomationExecutions >= MAX_NESTED_AUTOMATION_EXECUTIONS) { this._api.getNotificationManager().setNotification({ heading: { text: localize('error.too_many_automations'), @@ -76,12 +98,13 @@ export class AutomationsManager { return; } - await this._api - .getActionsManager() - .executeActions({ actions, triggerData: result.triggerData }); - - --this._nestedAutomationExecutions; + ++this._nestedAutomationExecutions; + try { + await this._api.getActionsManager().executeActions({ actions, triggerData }); + } finally { + --this._nestedAutomationExecutions; + } }; - runActions(actions); + runActions(automation.actions); } } diff --git a/src/card-controller/call/manager.ts b/src/card-controller/call/manager.ts index 9dd1c175..496c977c 100644 --- a/src/card-controller/call/manager.ts +++ b/src/card-controller/call/manager.ts @@ -1,5 +1,5 @@ import { createNotificationFromText } from '../../components-lib/notification/factory'; -import { ConditionStateChange } from '../../conditions/types'; +import { ConditionStateChange } from '../../condition-trigger/conditions/types'; import { localize } from '../../localize/localize'; import { Timer } from '../../utils/timer'; import { getStreamCameraID } from '../../view/substream'; diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/camera-triggers-manager.ts similarity index 98% rename from src/card-controller/triggers-manager.ts rename to src/card-controller/camera-triggers-manager.ts index ba5c0b04..9c33b522 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/camera-triggers-manager.ts @@ -2,7 +2,7 @@ import { maxBy, throttle } from 'lodash-es'; import { CameraEvent } from '../camera-manager/types'; import { isTriggeredState } from '../ha/is-triggered-state'; import { Timer } from '../utils/timer'; -import { CardTriggersAPI } from './types'; +import { CardCameraTriggersAPI } from './types'; interface CameraTriggerState { // The time of the most recent trigger event. Used to determine the most @@ -26,15 +26,15 @@ interface CameraTriggerState { untriggerForceTimer?: Timer; } -export class TriggersManager { - private _api: CardTriggersAPI; +export class CameraTriggersManager { + private _api: CardCameraTriggersAPI; private _states: Map = new Map(); private _throttledTriggerAction = throttle(this._triggerAction.bind(this), 1000, { trailing: true, }); - constructor(api: CardTriggersAPI) { + constructor(api: CardCameraTriggersAPI) { this._api = api; } diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index 9d169ff8..53d07bd9 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -188,7 +188,7 @@ export class CardElementManager { // reconnection, to ensure the state subscription/unsubscription works // correctly and triggers that changed while detached are picked up. // Reset trigger state first to stop stale timers and clear condition state. - this._api.getTriggersManager().reset(); + this._api.getCameraTriggersManager().reset(); this._api.getCallManager().uninitialize(); this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS); diff --git a/src/card-controller/config/load-control-entities.ts b/src/card-controller/config/load-control-entities.ts index c4418753..64425abd 100644 --- a/src/card-controller/config/load-control-entities.ts +++ b/src/card-controller/config/load-control-entities.ts @@ -22,9 +22,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { const automations: TaggedAutomation[] = [ { - conditions: [ + triggers: [ { - condition: 'config' as const, + trigger: 'config' as const, paths: ['cameras', 'remote_control.entities.camera'], }, ], @@ -38,9 +38,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { tag: automationTag, }, { - conditions: [ + triggers: [ { - condition: 'camera' as const, + trigger: 'camera' as const, }, ], actions: [ @@ -63,9 +63,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { // this one, as automations only run *after* the card is initialized (and // it very likely will not yet be). Instead, wait to be initialized, then // set the camera. - conditions: [ + triggers: [ { - condition: 'initialized' as const, + trigger: 'initialized' as const, }, ], actions: [ @@ -81,15 +81,15 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { tag: automationTag, }, { - conditions: [ + triggers: [ { - condition: 'state' as const, - entity: cameraControlEntity, + trigger: 'state' as const, + entity_id: cameraControlEntity, }, ], actions: [ // When the entity state changes, updated the selected option. - createCameraAction('{{ advanced_camera_card.trigger.state.to }}'), + createCameraAction('{{ trigger.to_state.state }}'), ], tag: automationTag, }, diff --git a/src/card-controller/config/load-keyboard-shortcuts.ts b/src/card-controller/config/load-keyboard-shortcuts.ts index 8077f2b6..f30e2f41 100644 --- a/src/card-controller/config/load-keyboard-shortcuts.ts +++ b/src/card-controller/config/load-keyboard-shortcuts.ts @@ -64,9 +64,9 @@ const convertKeyboardShortcutsToAutomations = ( } automations.push({ - conditions: [ + triggers: [ { - condition: 'key' as const, + trigger: 'key' as const, key: shortcut.key, state: 'down', shift: shortcut.shift, @@ -85,9 +85,9 @@ const convertKeyboardShortcutsToAutomations = ( }); automations.push({ - conditions: [ + triggers: [ { - condition: 'key' as const, + trigger: 'key' as const, key: shortcut.key, state: 'up', }, @@ -105,9 +105,9 @@ const convertKeyboardShortcutsToAutomations = ( const homeShortcut = shortcuts.ptz_home; if (homeShortcut) { automations.push({ - conditions: [ + triggers: [ { - condition: 'key' as const, + trigger: 'key' as const, key: homeShortcut.key, state: 'down', shift: homeShortcut.shift, diff --git a/src/card-controller/config/overrides-manager.ts b/src/card-controller/config/overrides-manager.ts index 775d9f4e..03cefd89 100644 --- a/src/card-controller/config/overrides-manager.ts +++ b/src/card-controller/config/overrides-manager.ts @@ -1,6 +1,6 @@ import { get, merge } from 'lodash-es'; -import { ConditionsManager } from '../../conditions/conditions-manager'; -import { ConditionStateManagerReadonlyInterface } from '../../conditions/types'; +import { ConditionsManager } from '../../condition-trigger/conditions/conditions-manager'; +import { ConditionStateManagerReadonlyInterface } from '../../condition-trigger/conditions/types'; import { copyConfig, deleteConfigValue, diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index ebd91cee..2f78c67d 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -1,6 +1,6 @@ import { ReactiveController } from 'lit'; import { CameraManager } from '../camera-manager/manager'; -import { ConditionStateManager } from '../conditions/state-manager'; +import { ConditionStateManager } from '../condition-trigger/conditions/state-manager'; import { AdvancedCameraCardConfig } from '../config/schema/types'; import { DeviceRegistryManager } from '../ha/registry/device'; import { DeviceCache } from '../ha/registry/device/types'; @@ -11,6 +11,7 @@ import { LovelaceCardEditor } from '../ha/types'; import { ActionsManager } from './actions/actions-manager'; import { AutomationsManager } from './automations-manager'; import { CallManager } from './call/manager'; +import { CameraTriggersManager } from './camera-triggers-manager'; import { CameraURLManager } from './camera-url-manager'; import { CardElementManager, @@ -40,11 +41,11 @@ import { QueryStringManager } from './query-string-manager'; import { StatusBarItemManager } from './status-bar-item-manager'; import { StyleManager } from './style-manager'; import { TemplateRenderer } from './templates'; -import { TriggersManager } from './triggers-manager'; import { CardActionsManagerAPI, CardAutomationsAPI, CardCameraAPI, + CardCameraTriggersAPI, CardCameraURLAPI, CardConditionAPI, CardConfigAPI, @@ -66,7 +67,6 @@ import { CardPIPAPI, CardQueryStringAPI, CardStyleAPI, - CardTriggersAPI, CardViewAPI, } from './types'; import { ViewItemManager } from './view/item-manager'; @@ -98,7 +98,7 @@ export class CardController CardNotificationAPI, CardQueryStringAPI, CardStyleAPI, - CardTriggersAPI, + CardCameraTriggersAPI, CardViewAPI, ReactiveController { @@ -137,7 +137,7 @@ export class CardController private _queryStringManager = new QueryStringManager(this); private _statusBarItemManager = new StatusBarItemManager(this); private _styleManager = new StyleManager(this); - private _triggersManager = new TriggersManager(this); + private _cameraTriggersManager = new CameraTriggersManager(this); private _viewManager = new ViewManager(this); private _viewItemManager = new ViewItemManager(this); @@ -305,8 +305,8 @@ export class CardController return this._styleManager; } - public getTriggersManager(): TriggersManager { - return this._triggersManager; + public getCameraTriggersManager(): CameraTriggersManager { + return this._cameraTriggersManager; } public getViewManager(): ViewManager { diff --git a/src/card-controller/default-manager.ts b/src/card-controller/default-manager.ts index 4d7ed437..ea1ec9f2 100644 --- a/src/card-controller/default-manager.ts +++ b/src/card-controller/default-manager.ts @@ -55,9 +55,9 @@ export class DefaultManager { this._api.getAutomationsManager().addAutomations([ { actions: [createGeneralAction('default')], - conditions: [ + triggers: [ { - condition: 'interaction' as const, + trigger: 'interaction' as const, interaction: false, }, ], diff --git a/src/card-controller/folders/executor.ts b/src/card-controller/folders/executor.ts index e8e9022d..d01a81e8 100644 --- a/src/card-controller/folders/executor.ts +++ b/src/card-controller/folders/executor.ts @@ -1,4 +1,4 @@ -import { ConditionState } from '../../conditions/types'; +import { ConditionState } from '../../condition-trigger/conditions/types'; import { FolderConfig, FolderType, folderTypeSchema } from '../../config/schema/folders'; import { HomeAssistant } from '../../ha/types'; import { Endpoint } from '../../types'; diff --git a/src/card-controller/folders/ha/engine.ts b/src/card-controller/folders/ha/engine.ts index 268977cd..5130f9ca 100644 --- a/src/card-controller/folders/ha/engine.ts +++ b/src/card-controller/folders/ha/engine.ts @@ -1,6 +1,6 @@ import { sub } from 'date-fns'; import { NonEmptyTuple } from 'type-fest'; -import { ConditionState } from '../../../conditions/types'; +import { ConditionState } from '../../../condition-trigger/conditions/types'; import { FolderConfig, folderTypeSchema, diff --git a/src/card-controller/folders/ha/media-matcher.ts b/src/card-controller/folders/ha/media-matcher.ts index f3668538..0fc9703b 100644 --- a/src/card-controller/folders/ha/media-matcher.ts +++ b/src/card-controller/folders/ha/media-matcher.ts @@ -1,5 +1,5 @@ import { sub } from 'date-fns'; -import { ConditionState } from '../../../conditions/types'; +import { ConditionState } from '../../../condition-trigger/conditions/types'; import { DateMatcher, Matcher, diff --git a/src/card-controller/folders/manager.ts b/src/card-controller/folders/manager.ts index 1184f8bc..cfeede6f 100644 --- a/src/card-controller/folders/manager.ts +++ b/src/card-controller/folders/manager.ts @@ -1,5 +1,5 @@ import { cloneDeep } from 'lodash-es'; -import { ConditionState } from '../../conditions/types'; +import { ConditionState } from '../../condition-trigger/conditions/types'; import { FolderConfig, FolderConfigWithoutID } from '../../config/schema/folders'; import { localize } from '../../localize/localize'; import { hasUnsupportedFilters } from '../../query-source.js'; diff --git a/src/card-controller/folders/types.ts b/src/card-controller/folders/types.ts index 8afb9c6f..194edeaa 100644 --- a/src/card-controller/folders/types.ts +++ b/src/card-controller/folders/types.ts @@ -1,5 +1,5 @@ import { NonEmptyTuple } from 'type-fest'; -import { ConditionState } from '../../conditions/types'; +import { ConditionState } from '../../condition-trigger/conditions/types'; import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders'; import { ResolvedMediaCache } from '../../ha/resolved-media'; import { HomeAssistant } from '../../ha/types'; diff --git a/src/card-controller/fullscreen/webkit/index.ts b/src/card-controller/fullscreen/webkit/index.ts index 899242f6..112d249a 100644 --- a/src/card-controller/fullscreen/webkit/index.ts +++ b/src/card-controller/fullscreen/webkit/index.ts @@ -1,4 +1,4 @@ -import { ConditionStateChange } from '../../../conditions/types'; +import { ConditionStateChange } from '../../../condition-trigger/conditions/types'; import { WebkitHTMLVideoElement } from '../../../types'; import { Timer } from '../../../utils/timer'; import { FullscreenProviderBase } from '../provider'; diff --git a/src/card-controller/hass/event-watcher.ts b/src/card-controller/hass/event-watcher.ts index 1ae05cc8..b1896a28 100644 --- a/src/card-controller/hass/event-watcher.ts +++ b/src/card-controller/hass/event-watcher.ts @@ -1,6 +1,6 @@ import { HassEvent } from 'home-assistant-js-websocket'; import { HomeAssistant } from '../../ha/types'; -import { KeyedSubscriptionManager } from '../../utils/keyed-subscription-manager'; +import { KeyedSubscriptionManager } from '../../utils/concurrency/keyed-subscription-manager'; export interface EventSubscriptionRequest { event_type: string; diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts index 98681a2f..efdc85ee 100644 --- a/src/card-controller/initialization-manager.ts +++ b/src/card-controller/initialization-manager.ts @@ -160,7 +160,7 @@ export class InitializationManager { this._initializer.initializeIfNecessary( InitializationAspect.INITIAL_TRIGGER, async () => { - await this._api.getTriggersManager().handleInitialCameraTriggers(); + await this._api.getCameraTriggersManager().handleInitialCameraTriggers(); // Force a card update to continue the initialization. this._api.getCardElementManager().update(); diff --git a/src/card-controller/issues/factory.ts b/src/card-controller/issues/factory.ts index 17f40d4a..b9bca7aa 100644 --- a/src/card-controller/issues/factory.ts +++ b/src/card-controller/issues/factory.ts @@ -2,6 +2,7 @@ import { CardIssueManagerAPI } from '../types'; import { IssueManager } from './issue-manager'; import { ConfigErrorIssue } from './issues/config-error'; import { ConfigUpgradeIssue } from './issues/config-upgrade'; +import { ConfigUpgradeFailureIssue } from './issues/config-upgrade-failure'; import { ConnectionIssue } from './issues/connection'; import { InitializationIssue } from './issues/initialization'; import { LegacyResourceIssue } from './issues/legacy-resource'; @@ -19,6 +20,7 @@ export const createIssueManager = (api: CardIssueManagerAPI): IssueManager => { // full-card issue. Register broader/more critical issues first. manager.addIssue(new ConfigErrorIssue()); manager.addIssue(new ConfigUpgradeIssue(api)); + manager.addIssue(new ConfigUpgradeFailureIssue(api)); manager.addIssue(new ViewIncompatibleIssue(api)); manager.addIssue(new ConnectionIssue()); manager.addIssue(new InitializationIssue(api)); diff --git a/src/card-controller/issues/issue-manager.ts b/src/card-controller/issues/issue-manager.ts index 44b01af4..707b481b 100644 --- a/src/card-controller/issues/issue-manager.ts +++ b/src/card-controller/issues/issue-manager.ts @@ -1,5 +1,5 @@ import type { IssueTriggerContext } from 'issue'; -import { ConditionStateChange } from '../../conditions/types'; +import { ConditionStateChange } from '../../condition-trigger/conditions/types'; import { isActionAllowedBasedOnInteractionState } from '../../utils/interaction-mode'; import { Timer } from '../../utils/timer'; import { CardIssueManagerAPI } from '../types'; diff --git a/src/card-controller/issues/issues/config-upgrade-failure.ts b/src/card-controller/issues/issues/config-upgrade-failure.ts new file mode 100644 index 00000000..66f173c7 --- /dev/null +++ b/src/card-controller/issues/issues/config-upgrade-failure.ts @@ -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 { + 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'), + }, + }, + }; + } +} diff --git a/src/card-controller/issues/issues/connection.ts b/src/card-controller/issues/issues/connection.ts index 0c0d89c6..ae34e2db 100644 --- a/src/card-controller/issues/issues/connection.ts +++ b/src/card-controller/issues/issues/connection.ts @@ -1,5 +1,5 @@ import { STATE_RUNNING } from 'home-assistant-js-websocket'; -import { ConditionState } from '../../../conditions/types.js'; +import { ConditionState } from '../../../condition-trigger/conditions/types.js'; import { localize } from '../../../localize/localize.js'; import { Issue, IssueDescription } from '../types.js'; diff --git a/src/card-controller/issues/issues/media-load.ts b/src/card-controller/issues/issues/media-load.ts index ff2c6b00..d11d627b 100644 --- a/src/card-controller/issues/issues/media-load.ts +++ b/src/card-controller/issues/issues/media-load.ts @@ -1,5 +1,5 @@ import type { IssueTriggerContext } from 'issue'; -import { ConditionState } from '../../../conditions/types.js'; +import { ConditionState } from '../../../condition-trigger/conditions/types.js'; import { Notification } from '../../../config/schema/actions/types.js'; import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js'; import { localize } from '../../../localize/localize.js'; diff --git a/src/card-controller/issues/state-manager.ts b/src/card-controller/issues/state-manager.ts index 9ea735a4..52cd8b8b 100644 --- a/src/card-controller/issues/state-manager.ts +++ b/src/card-controller/issues/state-manager.ts @@ -1,6 +1,6 @@ import type { IssueTriggerContext } from 'issue'; import { summarizeNotification } from '../../components-lib/notification/summarize'; -import { ConditionState } from '../../conditions/types'; +import { ConditionState } from '../../condition-trigger/conditions/types'; import { Notification } from '../../config/schema/actions/types'; import { HomeAssistant } from '../../ha/types'; import { isTruthy } from '../../utils/basic'; diff --git a/src/card-controller/issues/types.ts b/src/card-controller/issues/types.ts index f021b0f4..357fcea3 100644 --- a/src/card-controller/issues/types.ts +++ b/src/card-controller/issues/types.ts @@ -1,5 +1,5 @@ import type { IssueTriggerContext } from 'issue'; -import { ConditionState } from '../../conditions/types'; +import { ConditionState } from '../../condition-trigger/conditions/types'; import { Notification } from '../../config/schema/actions/types'; import { HomeAssistant } from '../../ha/types'; import { Severity } from '../../severity'; @@ -7,6 +7,7 @@ import { Severity } from '../../severity'; export type IssueKey = | 'config_error' | 'config_upgrade' + | 'config_upgrade_failure' | 'connection' | 'initialization' | 'legacy_resource' diff --git a/src/card-controller/pip-manager.ts b/src/card-controller/pip-manager.ts index 35151703..fc7b056e 100644 --- a/src/card-controller/pip-manager.ts +++ b/src/card-controller/pip-manager.ts @@ -1,4 +1,4 @@ -import { ConditionStateChange } from '../conditions/types'; +import { ConditionStateChange } from '../condition-trigger/conditions/types'; import { PIPElement } from '../types'; import { CardPIPAPI } from './types'; diff --git a/src/card-controller/templates/index.ts b/src/card-controller/templates/index.ts index a7328323..a423451a 100644 --- a/src/card-controller/templates/index.ts +++ b/src/card-controller/templates/index.ts @@ -1,28 +1,20 @@ import { HASS, renderTemplate } from 'ha-nunjucks/dist'; -import { ConditionState, ConditionsTriggerData } from '../../conditions/types'; +import { ConditionState } from '../../condition-trigger/conditions/types'; +import { TriggerData } from '../../condition-trigger/triggers/types'; import { HomeAssistant } from '../../ha/types'; - -interface TemplateMediaData { - title: string; - is_folder: boolean; -} -interface TemplateContextInternal { - camera?: string; - view?: string; - trigger?: ConditionsTriggerData; - media?: TemplateMediaData; -} +import { isRecord } from '../../utils/basic'; +import { TemplateACCNamespace, TemplateMediaData } from './types'; interface TemplateContext { - advanced_camera_card: TemplateContextInternal; + acc: TemplateACCNamespace; - // Convenient alias. - acc: TemplateContextInternal; + // The HA-native top-level `trigger`, set only when a trigger fired. + trigger?: TriggerData; } interface TemplateRenderOptions { conditionState?: ConditionState; - triggerData?: ConditionsTriggerData; + triggerData?: TriggerData; mediaData?: TemplateMediaData; } @@ -45,22 +37,23 @@ export class TemplateRenderer { if ( !options?.conditionState?.camera && !options?.conditionState?.view && + !options?.conditionState?.config && !options?.triggerData && !options?.mediaData ) { return; } - const advancedCameraCardContext: TemplateContextInternal = { + const acc: TemplateACCNamespace = { ...(options?.conditionState?.camera && { camera: options.conditionState.camera }), ...(options?.conditionState?.view && { view: options.conditionState.view }), - ...(options?.triggerData && { trigger: options.triggerData }), + ...(options?.conditionState?.config && { config: options.conditionState.config }), ...(options?.mediaData && { media: options.mediaData }), }; return { - acc: advancedCameraCardContext, - advanced_camera_card: advancedCameraCardContext, + acc, + ...(options?.triggerData && { trigger: options.triggerData }), }; } @@ -81,7 +74,7 @@ export class TemplateRenderer { return data.map((item) => this._renderTemplateRecursively(hass, item, templateContext), ); - } else if (typeof data === 'object' && data !== null) { + } else if (isRecord(data)) { const result = {}; for (const key in data) { result[key] = this._renderTemplateRecursively(hass, data[key], templateContext); diff --git a/src/card-controller/templates/types.ts b/src/card-controller/templates/types.ts new file mode 100644 index 00000000..2fed778a --- /dev/null +++ b/src/card-controller/templates/types.ts @@ -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; +} diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 5b6734fa..39f0154a 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -1,5 +1,5 @@ import type { CameraManager } from '../camera-manager/manager'; -import type { ConditionStateManager } from '../conditions/state-manager'; +import type { ConditionStateManager } from '../condition-trigger/conditions/state-manager'; import type { Automation } from '../config/schema/automations'; import type { DeviceRegistryManager } from '../ha/registry/device'; import type { EntityRegistryManager } from '../ha/registry/entity/types'; @@ -8,6 +8,7 @@ import type { EffectsManagerInterface } from '../types'; import type { ActionsManager } from './actions/actions-manager'; import type { AutomationsManager } from './automations-manager'; import type { CallManager } from './call/manager'; +import type { CameraTriggersManager } from './camera-triggers-manager'; import type { CameraURLManager } from './camera-url-manager'; import type { CardElementManager } from './card-element-manager'; import type { ConfigManager } from './config/config-manager'; @@ -29,7 +30,6 @@ import type { PIPManager } from './pip-manager'; import type { QueryStringManager } from './query-string-manager'; import type { StatusBarItemManager } from './status-bar-item-manager'; import type { StyleManager } from './style-manager'; -import type { TriggersManager } from './triggers-manager'; import type { ViewItemManager } from './view/item-manager'; import type { ViewManager } from './view/view-manager'; @@ -61,7 +61,7 @@ export interface CardActionsAPI { getPIPManager(): PIPManager; getIssueManager(): IssueManager; getStatusBarItemManager(): StatusBarItemManager; - getTriggersManager(): TriggersManager; + getCameraTriggersManager(): CameraTriggersManager; getViewItemManager(): ViewItemManager; getViewManager(): ViewManager; } @@ -94,7 +94,7 @@ export interface CardCameraAPI { getEntityRegistryManager(): EntityRegistryManager; getHASSManager(): HASSManager; getResolvedMediaCache(): ResolvedMediaCache; - getTriggersManager(): TriggersManager; + getCameraTriggersManager(): CameraTriggersManager; } export interface CardCameraURLAPI { @@ -139,7 +139,7 @@ export interface CardDefaultManagerAPI { getConfigManager(): ConfigManager; getHASSManager(): HASSManager; getInteractionManager(): InteractionManager; - getTriggersManager(): TriggersManager; + getCameraTriggersManager(): CameraTriggersManager; getViewManager(): ViewManager; } @@ -170,7 +170,7 @@ export interface CardElementAPI { getPIPManager(): PIPManager; getIssueManager(): IssueManager; getQueryStringManager(): QueryStringManager; - getTriggersManager(): TriggersManager; + getCameraTriggersManager(): CameraTriggersManager; getViewManager(): ViewManager; } @@ -209,7 +209,7 @@ export interface CardHASSAPI { getInteractionManager(): InteractionManager; getMediaPlayerManager(): MediaPlayerManager; getStyleManager(): StyleManager; - getTriggersManager(): TriggersManager; + getCameraTriggersManager(): CameraTriggersManager; getViewManager(): ViewManager; } @@ -230,7 +230,7 @@ export interface CardInitializerAPI { getIssueManager(): IssueManager; getQueryStringManager(): QueryStringManager; getResolvedMediaCache(): ResolvedMediaCache; - getTriggersManager(): TriggersManager; + getCameraTriggersManager(): CameraTriggersManager; getViewManager(): ViewManager; } @@ -239,7 +239,7 @@ export interface CardInteractionAPI { getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; getStyleManager(): StyleManager; - getTriggersManager(): TriggersManager; + getCameraTriggersManager(): CameraTriggersManager; getViewManager(): ViewManager; } @@ -314,7 +314,7 @@ export interface CardStyleAPI { getViewManager(): ViewManager; } -export interface CardTriggersAPI { +export interface CardCameraTriggersAPI { getCallManager(): CallManager; getCameraManager(): CameraManager; getConditionStateManager(): ConditionStateManager; @@ -339,7 +339,7 @@ export interface CardViewAPI { getIssueManager(): IssueManager; getQueryStringManager(): QueryStringManager; getStyleManager(): StyleManager; - getTriggersManager(): TriggersManager; + getCameraTriggersManager(): CameraTriggersManager; } // ************************************************************************* diff --git a/src/card.ts b/src/card.ts index b0f8b16c..89fa06a7 100644 --- a/src/card.ts +++ b/src/card.ts @@ -25,7 +25,7 @@ import './components/status-bar'; import './components/thumbnail-carousel.js'; import './components/views.js'; import { AdvancedCameraCardViews } from './components/views.js'; -import { ConditionStateManagerGetEvent } from './conditions/state-manager-via-event.js'; +import { ConditionStateManagerGetEvent } from './condition-trigger/conditions/state-manager-via-event.js'; import { StatusBarItem } from './config/schema/actions/types.js'; import { MenuItem } from './config/schema/elements/custom/menu/types.js'; import { AdvancedCameraCardConfig } from './config/schema/types.js'; @@ -457,7 +457,7 @@ class AdvancedCameraCard extends LitElement { .locked=${this._controller.getLockManager().isLocked()} .conditionStateManager=${this._controller.getConditionStateManager()} .triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status - ? this._controller.getTriggersManager().getTriggeredCameraIDs() + ? this._controller.getCameraTriggersManager().getTriggeredCameraIDs() : undefined} .deviceRegistryManager=${this._controller.getDeviceRegistryManager()} .issues=${this._controller diff --git a/src/components-lib/menu-controller.ts b/src/components-lib/menu-controller.ts index 09c05d51..3ab2be74 100644 --- a/src/components-lib/menu-controller.ts +++ b/src/components-lib/menu-controller.ts @@ -8,7 +8,7 @@ import { MENU_PRIORITY_MAX } from '../config/schema/common/const.js'; import type { MenuItem } from '../config/schema/elements/custom/menu/types.js'; import type { MenuConfig } from '../config/schema/menu.js'; import type { Interaction } from '../types.js'; -import { getActionConfigGivenAction } from '../utils/action'; +import { getActionConfigGivenAction, isStandardAction } from '../utils/action'; import { arrayify, isTruthy } from '../utils/basic.js'; import { AutoHideState, isAutoHidden as evaluateAutoHidden } from './auto-hide.js'; @@ -225,6 +225,7 @@ export class MenuController { private _isMenuToggleAction(action: ActionConfig): boolean { return ( + isStandardAction(action) && action.action === 'fire-dom-event' && action.advanced_camera_card_action === 'menu_toggle' ); diff --git a/src/components-lib/timeline/controller.ts b/src/components-lib/timeline/controller.ts index b07e5d8f..d37cbad6 100644 --- a/src/components-lib/timeline/controller.ts +++ b/src/components-lib/timeline/controller.ts @@ -19,7 +19,7 @@ import { FoldersManager } from '../../card-controller/folders/manager'; import { ViewItemManager } from '../../card-controller/view/item-manager'; import { MergeContextViewModifier } from '../../card-controller/view/modifiers/merge-context'; import { ViewManagerEpoch } from '../../card-controller/view/types'; -import { ConditionStateManagerReadonlyInterface } from '../../conditions/types'; +import { ConditionStateManagerReadonlyInterface } from '../../condition-trigger/conditions/types'; import { CameraConfig } from '../../config/schema/cameras'; import { AdvancedCameraCardView } from '../../config/schema/common/const'; import { ThumbnailsControlBaseConfig } from '../../config/schema/common/controls/thumbnails'; diff --git a/src/components-lib/timeline/source.ts b/src/components-lib/timeline/source.ts index 28642bf7..a1a88e8b 100644 --- a/src/components-lib/timeline/source.ts +++ b/src/components-lib/timeline/source.ts @@ -11,7 +11,7 @@ import { RecordingSegment } from '../../camera-manager/types'; import { capEndDate } from '../../camera-manager/utils/cap-end-date'; import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly'; import { FoldersManager } from '../../card-controller/folders/manager'; -import { ConditionStateManagerReadonlyInterface } from '../../conditions/types'; +import { ConditionStateManagerReadonlyInterface } from '../../condition-trigger/conditions/types'; import { FolderConfig } from '../../config/schema/folders'; import { errorToConsole } from '../../utils/basic.js'; import { ViewItem, ViewMedia } from '../../view/item'; diff --git a/src/components/elements.ts b/src/components/elements.ts index b005ad96..304c9de8 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -10,9 +10,9 @@ import { customElement, property, state } from 'lit/decorators.js'; import { isEqual } from 'lodash-es'; import { IssueTriggerEventData } from '../card-controller/issues/types.js'; import { TemplateRenderer } from '../card-controller/templates/index.js'; -import { ConditionsManager } from '../conditions/conditions-manager.js'; -import { getConditionStateManagerViaEvent } from '../conditions/state-manager-via-event.js'; -import { ConditionStateManager } from '../conditions/state-manager.js'; +import { ConditionsManager } from '../condition-trigger/conditions/conditions-manager.js'; +import { getConditionStateManagerViaEvent } from '../condition-trigger/conditions/state-manager-via-event.js'; +import { ConditionStateManager } from '../condition-trigger/conditions/state-manager.js'; import { StatusBarIcon, StatusBarImage, diff --git a/src/components/gallery/gallery.ts b/src/components/gallery/gallery.ts index e36e3582..a3e462af 100644 --- a/src/components/gallery/gallery.ts +++ b/src/components/gallery/gallery.ts @@ -22,7 +22,7 @@ import { navigateToMedia, navigateUp, } from '../../components-lib/navigation.js'; -import { ConditionStateManagerReadonlyInterface } from '../../conditions/types.js'; +import { ConditionStateManagerReadonlyInterface } from '../../condition-trigger/conditions/types.js'; import { MediaGalleryConfig } from '../../config/schema/media-gallery.js'; import { CardWideConfig } from '../../config/schema/types.js'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const.js'; diff --git a/src/components/surround.ts b/src/components/surround.ts index 6018f144..64873f6b 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -11,7 +11,7 @@ import { CameraManager } from '../camera-manager/manager.js'; import { FoldersManager } from '../card-controller/folders/manager.js'; import { ViewItemManager } from '../card-controller/view/item-manager.js'; import { ViewManagerEpoch } from '../card-controller/view/types.js'; -import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js'; +import { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types.js'; import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js'; import { MiniTimelineControlConfig } from '../config/schema/common/controls/timeline.js'; import { CardWideConfig } from '../config/schema/types.js'; diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 3a947754..4a1f61a0 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -19,7 +19,7 @@ import { ThumbnailDataRequestEvent, TimelineItemClickAction, } from '../components-lib/timeline/types'; -import { ConditionStateManagerReadonlyInterface } from '../conditions/types'; +import { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types'; import { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails'; import { TimelineCoreConfig } from '../config/schema/common/controls/timeline'; import { CardWideConfig } from '../config/schema/types'; diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 3bde8942..721f092e 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -4,7 +4,7 @@ import { CameraManager } from '../camera-manager/manager'; import { FoldersManager } from '../card-controller/folders/manager'; import { ViewItemManager } from '../card-controller/view/item-manager'; import { ViewManagerEpoch } from '../card-controller/view/types'; -import { ConditionStateManagerReadonlyInterface } from '../conditions/types'; +import { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types'; import { TimelineConfig } from '../config/schema/timeline'; import { CardWideConfig } from '../config/schema/types'; import { HomeAssistant } from '../ha/types'; diff --git a/src/components/views.ts b/src/components/views.ts index 263dfb71..356ec3fb 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -16,7 +16,7 @@ import { MicrophoneState } from '../card-controller/types.js'; import { ViewItemManager } from '../card-controller/view/item-manager.js'; import { ViewManagerEpoch } from '../card-controller/view/types.js'; import { CallSession } from '../card-controller/call/types.js'; -import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js'; +import { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types.js'; import { AdvancedCameraCardConfig, CardWideConfig } from '../config/schema/types.js'; import { RawAdvancedCameraCardConfig } from '../config/types.js'; import { DeviceRegistryManager } from '../ha/registry/device/index.js'; diff --git a/src/condition-trigger/README.md b/src/condition-trigger/README.md new file mode 100644 index 00000000..6832c9f9 --- /dev/null +++ b/src/condition-trigger/README.md @@ -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. diff --git a/src/condition-trigger/common/is-enabled.ts b/src/condition-trigger/common/is-enabled.ts new file mode 100644 index 00000000..a81a1125 --- /dev/null +++ b/src/condition-trigger/common/is-enabled.ts @@ -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 + ); +}; diff --git a/src/condition-trigger/common/media-query-watcher.ts b/src/condition-trigger/common/media-query-watcher.ts new file mode 100644 index 00000000..ef71e136 --- /dev/null +++ b/src/condition-trigger/common/media-query-watcher.ts @@ -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?.(); +} diff --git a/src/condition-trigger/common/numeric-state.ts b/src/condition-trigger/common/numeric-state.ts new file mode 100644 index 00000000..9ac34eb0 --- /dev/null +++ b/src/condition-trigger/common/numeric-state.ts @@ -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) + ); +}; diff --git a/src/condition-trigger/common/time-period.ts b/src/condition-trigger/common/time-period.ts new file mode 100644 index 00000000..078e5a9a --- /dev/null +++ b/src/condition-trigger/common/time-period.ts @@ -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 }), + ); +}; diff --git a/src/condition-trigger/conditions/conditions-manager.ts b/src/condition-trigger/conditions/conditions-manager.ts new file mode 100644 index 00000000..a0c0e334 --- /dev/null +++ b/src/condition-trigger/conditions/conditions-manager.ts @@ -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), + ); + } + } + } +} diff --git a/src/condition-trigger/conditions/conditions/and.ts b/src/condition-trigger/conditions/conditions/and.ts new file mode 100644 index 00000000..77d1a6f8 --- /dev/null +++ b/src/condition-trigger/conditions/conditions/and.ts @@ -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 }; + } +} diff --git a/src/conditions/conditions/call.ts b/src/condition-trigger/conditions/conditions/call.ts similarity index 51% rename from src/conditions/conditions/call.ts rename to src/condition-trigger/conditions/conditions/call.ts index 7db503a1..88828660 100644 --- a/src/conditions/conditions/call.ts +++ b/src/condition-trigger/conditions/conditions/call.ts @@ -1,16 +1,17 @@ +import { CallBase } from '../../../config/schema/condition-trigger/common/call'; import { ConditionsEvaluationResult, ConditionState } from '../types'; -import { ConditionEvaluator, ConditionOfType } from './types'; +import { ConditionEvaluator } from './types'; export class CallConditionEvaluator implements ConditionEvaluator { - private _condition: ConditionOfType<'call'>; + private _condition: CallBase; - constructor(condition: ConditionOfType<'call'>) { + constructor(condition: CallBase) { this._condition = condition; } public evaluate(newState?: ConditionState): ConditionsEvaluationResult { return { - result: (this._condition.call ?? true) === (newState?.call ?? false), + result: this._condition.call === (newState?.call ?? false), }; } } diff --git a/src/condition-trigger/conditions/conditions/camera.ts b/src/condition-trigger/conditions/conditions/camera.ts new file mode 100644 index 00000000..1a141636 --- /dev/null +++ b/src/condition-trigger/conditions/conditions/camera.ts @@ -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) }; + } +} diff --git a/src/conditions/conditions/composite.ts b/src/condition-trigger/conditions/conditions/composite.ts similarity index 55% rename from src/conditions/conditions/composite.ts rename to src/condition-trigger/conditions/conditions/composite.ts index 5d4cb1f4..4a22202d 100644 --- a/src/conditions/conditions/composite.ts +++ b/src/condition-trigger/conditions/conditions/composite.ts @@ -1,9 +1,10 @@ import { ConditionsEvaluationResult, ConditionState } from '../types'; -import { ConditionEvaluator, ConditionEvaluatorSubscriptionCallback } from './types'; +import { ConditionEvaluator, ExternalInvalidationSource } from './types'; /** * Base class for the `or`/`and`/`not` composites: each holds child evaluators - * and forwards subscription/teardown to them. Subclasses provide `evaluate`. + * and unions their external invalidation sources. Subclasses provide + * `evaluate`. */ export abstract class CompositeConditionEvaluator implements ConditionEvaluator { protected _children: ConditionEvaluator[]; @@ -17,11 +18,7 @@ export abstract class CompositeConditionEvaluator implements ConditionEvaluator oldState?: ConditionState, ): ConditionsEvaluationResult; - public subscribe(onChange: ConditionEvaluatorSubscriptionCallback): void { - this._children.forEach((child) => child.subscribe?.(onChange)); - } - - public destroy(): void { - this._children.forEach((child) => child.destroy?.()); + public get externalSources(): ExternalInvalidationSource[] { + return this._children.flatMap((child) => child.externalSources ?? []); } } diff --git a/src/conditions/conditions/display-mode.ts b/src/condition-trigger/conditions/conditions/display-mode.ts similarity index 63% rename from src/conditions/conditions/display-mode.ts rename to src/condition-trigger/conditions/conditions/display-mode.ts index 9220f0d4..30321810 100644 --- a/src/conditions/conditions/display-mode.ts +++ b/src/condition-trigger/conditions/conditions/display-mode.ts @@ -1,10 +1,11 @@ +import { DisplayModeBase } from '../../../config/schema/condition-trigger/common/display-mode'; import { ConditionsEvaluationResult, ConditionState } from '../types'; -import { ConditionEvaluator, ConditionOfType } from './types'; +import { ConditionEvaluator } from './types'; export class DisplayModeConditionEvaluator implements ConditionEvaluator { - private _condition: ConditionOfType<'display_mode'>; + private _condition: DisplayModeBase; - constructor(condition: ConditionOfType<'display_mode'>) { + constructor(condition: DisplayModeBase) { this._condition = condition; } diff --git a/src/conditions/conditions/expand.ts b/src/condition-trigger/conditions/conditions/expand.ts similarity index 65% rename from src/conditions/conditions/expand.ts rename to src/condition-trigger/conditions/conditions/expand.ts index 8073b22a..9fb9e52f 100644 --- a/src/conditions/conditions/expand.ts +++ b/src/condition-trigger/conditions/conditions/expand.ts @@ -1,10 +1,11 @@ +import { ExpandBase } from '../../../config/schema/condition-trigger/common/expand'; import { ConditionsEvaluationResult, ConditionState } from '../types'; -import { ConditionEvaluator, ConditionOfType } from './types'; +import { ConditionEvaluator } from './types'; export class ExpandConditionEvaluator implements ConditionEvaluator { - private _condition: ConditionOfType<'expand'>; + private _condition: ExpandBase; - constructor(condition: ConditionOfType<'expand'>) { + constructor(condition: ExpandBase) { this._condition = condition; } diff --git a/src/conditions/conditions/fullscreen.ts b/src/condition-trigger/conditions/conditions/fullscreen.ts similarity index 64% rename from src/conditions/conditions/fullscreen.ts rename to src/condition-trigger/conditions/conditions/fullscreen.ts index bb0b53a5..97bc79e8 100644 --- a/src/conditions/conditions/fullscreen.ts +++ b/src/condition-trigger/conditions/conditions/fullscreen.ts @@ -1,10 +1,11 @@ +import { FullscreenBase } from '../../../config/schema/condition-trigger/common/fullscreen'; import { ConditionsEvaluationResult, ConditionState } from '../types'; -import { ConditionEvaluator, ConditionOfType } from './types'; +import { ConditionEvaluator } from './types'; export class FullscreenConditionEvaluator implements ConditionEvaluator { - private _condition: ConditionOfType<'fullscreen'>; + private _condition: FullscreenBase; - constructor(condition: ConditionOfType<'fullscreen'>) { + constructor(condition: FullscreenBase) { this._condition = condition; } diff --git a/src/conditions/conditions/initialized.ts b/src/condition-trigger/conditions/conditions/initialized.ts similarity index 100% rename from src/conditions/conditions/initialized.ts rename to src/condition-trigger/conditions/conditions/initialized.ts diff --git a/src/conditions/conditions/interaction.ts b/src/condition-trigger/conditions/conditions/interaction.ts similarity index 64% rename from src/conditions/conditions/interaction.ts rename to src/condition-trigger/conditions/conditions/interaction.ts index 88da1342..385d675c 100644 --- a/src/conditions/conditions/interaction.ts +++ b/src/condition-trigger/conditions/conditions/interaction.ts @@ -1,10 +1,11 @@ +import { InteractionBase } from '../../../config/schema/condition-trigger/common/interaction'; import { ConditionsEvaluationResult, ConditionState } from '../types'; -import { ConditionEvaluator, ConditionOfType } from './types'; +import { ConditionEvaluator } from './types'; export class InteractionConditionEvaluator implements ConditionEvaluator { - private _condition: ConditionOfType<'interaction'>; + private _condition: InteractionBase; - constructor(condition: ConditionOfType<'interaction'>) { + constructor(condition: InteractionBase) { this._condition = condition; } diff --git a/src/condition-trigger/conditions/conditions/is-template-true.ts b/src/condition-trigger/conditions/conditions/is-template-true.ts new file mode 100644 index 00000000..e0944798 --- /dev/null +++ b/src/condition-trigger/conditions/conditions/is-template-true.ts @@ -0,0 +1,8 @@ +// Whether a rendered template result counts as true for a CONDITION. HA's +// `condition.py` `async_template` does `value.lower() == "true"`, so only +// `true` (case-insensitive) passes -- `yes`/`on`/`1` do NOT (unlike a trigger). +// The card's renderer returns native types, so the boolean a comparison +// template (e.g. `{{ a == b }}`) produces stringifies to "true" and also +// passes. +export const isTemplateTrue = (value: unknown): boolean => + String(value).toLowerCase() === 'true'; diff --git a/src/condition-trigger/conditions/conditions/key.ts b/src/condition-trigger/conditions/conditions/key.ts new file mode 100644 index 00000000..7b6500b1 --- /dev/null +++ b/src/condition-trigger/conditions/conditions/key.ts @@ -0,0 +1,30 @@ +import { KeyBase } from '../../../config/schema/condition-trigger/common/key'; +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator } from './types'; + +export class KeyConditionEvaluator implements ConditionEvaluator { + private _condition: KeyBase; + + constructor(condition: KeyBase) { + this._condition = condition; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + const condition = this._condition; + + // The condition schema requires `key`; the undefined check only guards the + // shared base type, on which it is declared optional. + if (condition.key === undefined || !newState?.keys?.[condition.key]) { + return { result: false }; + } + const pressed = newState.keys[condition.key]; + return { + result: + (condition.state ?? 'down') === pressed.state && + (condition.ctrl === undefined || condition.ctrl === !!pressed.ctrl) && + (condition.alt === undefined || condition.alt === !!pressed.alt) && + (condition.meta === undefined || condition.meta === !!pressed.meta) && + (condition.shift === undefined || condition.shift === !!pressed.shift), + }; + } +} diff --git a/src/conditions/conditions/media-loaded.ts b/src/condition-trigger/conditions/conditions/media-loaded.ts similarity index 65% rename from src/conditions/conditions/media-loaded.ts rename to src/condition-trigger/conditions/conditions/media-loaded.ts index b2718190..f4a0453e 100644 --- a/src/conditions/conditions/media-loaded.ts +++ b/src/condition-trigger/conditions/conditions/media-loaded.ts @@ -1,10 +1,11 @@ +import { MediaLoadedBase } from '../../../config/schema/condition-trigger/common/media-loaded'; import { ConditionsEvaluationResult, ConditionState } from '../types'; -import { ConditionEvaluator, ConditionOfType } from './types'; +import { ConditionEvaluator } from './types'; export class MediaLoadedConditionEvaluator implements ConditionEvaluator { - private _condition: ConditionOfType<'media_loaded'>; + private _condition: MediaLoadedBase; - constructor(condition: ConditionOfType<'media_loaded'>) { + constructor(condition: MediaLoadedBase) { this._condition = condition; } diff --git a/src/conditions/conditions/microphone.ts b/src/condition-trigger/conditions/conditions/microphone.ts similarity index 61% rename from src/conditions/conditions/microphone.ts rename to src/condition-trigger/conditions/conditions/microphone.ts index 5b0485d7..0b98c476 100644 --- a/src/conditions/conditions/microphone.ts +++ b/src/condition-trigger/conditions/conditions/microphone.ts @@ -1,10 +1,11 @@ +import { MicrophoneBase } from '../../../config/schema/condition-trigger/common/microphone'; import { ConditionsEvaluationResult, ConditionState } from '../types'; -import { ConditionEvaluator, ConditionOfType } from './types'; +import { ConditionEvaluator } from './types'; export class MicrophoneConditionEvaluator implements ConditionEvaluator { - private _condition: ConditionOfType<'microphone'>; + private _condition: MicrophoneBase; - constructor(condition: ConditionOfType<'microphone'>) { + constructor(condition: MicrophoneBase) { this._condition = condition; } diff --git a/src/conditions/conditions/not.ts b/src/condition-trigger/conditions/conditions/not.ts similarity index 57% rename from src/conditions/conditions/not.ts rename to src/condition-trigger/conditions/conditions/not.ts index 9aa3280d..437c0641 100644 --- a/src/conditions/conditions/not.ts +++ b/src/condition-trigger/conditions/conditions/not.ts @@ -2,14 +2,16 @@ import { ConditionsEvaluationResult, ConditionState } from '../types'; import { CompositeConditionEvaluator } from './composite'; export class NotConditionEvaluator extends CompositeConditionEvaluator { - // "Not" is an inverted `or` (NOR). There is no trigger data for "not - // triggering". + // "Not" is an inverted `or` (NOR): true when no child matches. public evaluate( newState?: ConditionState, oldState?: ConditionState, ): ConditionsEvaluationResult { - return { - result: !this._children.some((child) => child.evaluate(newState, oldState).result), - }; + for (const child of this._children) { + if (child.evaluate(newState, oldState).result) { + return { result: false }; + } + } + return { result: true }; } } diff --git a/src/condition-trigger/conditions/conditions/numeric-state.ts b/src/condition-trigger/conditions/conditions/numeric-state.ts new file mode 100644 index 00000000..c2840140 --- /dev/null +++ b/src/condition-trigger/conditions/conditions/numeric-state.ts @@ -0,0 +1,36 @@ +import { arrayify } from '../../../utils/basic'; +import { matchesNumericState } from '../../common/numeric-state'; +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './types'; + +export class NumericStateConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'numeric_state'>; + private _context: EvaluatorContext; + + constructor(condition: ConditionOfType<'numeric_state'>, context: EvaluatorContext) { + this._condition = condition; + this._context = context; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + const condition = this._condition; + + // `entity` is canonical; `entity_id` is the accepted automation-dialect alias. + // Either may be a list; with multiple entities all must match (HA's `match: all`). + const entityIDs = arrayify(condition.entity ?? condition.entity_id); + if (!entityIDs.length || !newState) { + return { result: false }; + } + + return { + result: entityIDs.every((entityID) => + matchesNumericState( + entityID, + newState, + condition, + this._context.templateRenderer, + ), + ), + }; + } +} diff --git a/src/conditions/conditions/or.ts b/src/condition-trigger/conditions/conditions/or.ts similarity index 77% rename from src/conditions/conditions/or.ts rename to src/condition-trigger/conditions/conditions/or.ts index 37172d9d..55fe0660 100644 --- a/src/conditions/conditions/or.ts +++ b/src/condition-trigger/conditions/conditions/or.ts @@ -7,9 +7,8 @@ export class OrConditionEvaluator extends CompositeConditionEvaluator { oldState?: ConditionState, ): ConditionsEvaluationResult { for (const child of this._children) { - const evaluation = child.evaluate(newState, oldState); - if (evaluation.result) { - return evaluation; + if (child.evaluate(newState, oldState).result) { + return { result: true }; } } return { result: false }; diff --git a/src/condition-trigger/conditions/conditions/screen.ts b/src/condition-trigger/conditions/conditions/screen.ts new file mode 100644 index 00000000..fa00c0ea --- /dev/null +++ b/src/condition-trigger/conditions/conditions/screen.ts @@ -0,0 +1,22 @@ +import { ScreenBase } from '../../../config/schema/condition-trigger/common/screen'; +import { MediaQueryWatcher } from '../../common/media-query-watcher'; +import { ConditionsEvaluationResult } from '../types'; +import { ConditionEvaluator, ExternalInvalidationSource } from './types'; + +export class ScreenConditionEvaluator implements ConditionEvaluator { + private _watcher: MediaQueryWatcher | null; + + constructor(condition: ScreenBase) { + this._watcher = condition.media_query + ? new MediaQueryWatcher(condition.media_query) + : null; + } + + public evaluate(): ConditionsEvaluationResult { + return { result: this._watcher?.matches() ?? false }; + } + + public get externalSources(): ExternalInvalidationSource[] { + return this._watcher ? [this._watcher] : []; + } +} diff --git a/src/condition-trigger/conditions/conditions/state.ts b/src/condition-trigger/conditions/conditions/state.ts new file mode 100644 index 00000000..cc7aa707 --- /dev/null +++ b/src/condition-trigger/conditions/conditions/state.ts @@ -0,0 +1,104 @@ +import { arrayify } from '../../../utils/basic'; +import { renderTimePeriodToSeconds } from '../../common/time-period'; +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './types'; + +// Resolve each expected value that names an entity present in `hass` to that +// entity's current state, accepting either the literal or the resolved value +// (i.e. HA's Lovelace state condition will resolve "state: input_boolean.foo" +// to "state: on" when input_boolean.foo is on). +const resolveExpectedStates = ( + values: string | string[], + state?: ConditionState, +): string[] => + // Cannot use `arrayify` as an empty-string expected value is a real value. + (Array.isArray(values) ? values : [values]).flatMap((value) => { + const resolved = state?.hass?.states?.[value]?.state; + return resolved !== undefined ? [value, resolved] : [value]; + }); + +export class StateConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'state'>; + private _context: EvaluatorContext; + + constructor(condition: ConditionOfType<'state'>, context: EvaluatorContext) { + this._condition = condition; + this._context = context; + } + + public evaluate( + newState?: ConditionState, + oldState?: ConditionState, + ): ConditionsEvaluationResult { + const condition = this._condition; + + // `entity` is canonical; `entity_id` is the accepted automation-dialect alias. + // Either may be a list; with multiple entities all must match (HA's `match: all`). + const entityIDs = arrayify(condition.entity ?? condition.entity_id); + if (!entityIDs.length) { + return { result: false }; + } + + // The compared value is the attribute when `attribute` is set, else the state. + const readValue = (entityID: string, state?: ConditionState): string | null => { + const stateObj = state?.hass?.states?.[entityID]; + if (!stateObj) { + return null; + } + if (condition.attribute) { + const value = stateObj.attributes?.[condition.attribute]; + return value === undefined || value === null ? null : String(value); + } + return stateObj.state; + }; + + const matchesEntity = (entityID: string): boolean => { + const fromValue = readValue(entityID, oldState); + const toValue = readValue(entityID, newState); + + let result: boolean; + if (condition.state === undefined && condition.state_not === undefined) { + // With neither `state` nor `state_not`, match any change of value. + result = toValue !== fromValue; + } else if (toValue === null) { + // A missing entity or attribute cannot match; an empty-string state is + // a real value, handled in the comparison below. + result = false; + } else { + result = + (condition.state === undefined || + resolveExpectedStates(condition.state, newState).includes(toValue)) && + (condition.state_not === undefined || + !resolveExpectedStates(condition.state_not, newState).includes(toValue)); + } + + // `for`: the match must have been held for at least the given duration. + // Evaluated against `last_changed` at evaluation time (correct for the + // point-in-time / ongoing-condition use). + if (result && condition.for !== undefined) { + const forSeconds = renderTimePeriodToSeconds( + this._context.templateRenderer, + condition.for, + newState, + ); + const lastChanged = newState?.hass?.states?.[entityID]?.last_changed; + if (forSeconds === null || !lastChanged) { + result = false; + } else { + const heldSeconds = + (new Date().getTime() - new Date(lastChanged).getTime()) / 1000; + result = heldSeconds >= forSeconds; + } + } + return result; + }; + + // `match: any` requires one entity to match; `all` (the default) requires all. + const result = + condition.match === 'any' + ? entityIDs.some(matchesEntity) + : entityIDs.every(matchesEntity); + + return { result }; + } +} diff --git a/src/conditions/conditions/template.ts b/src/condition-trigger/conditions/conditions/template.ts similarity index 68% rename from src/conditions/conditions/template.ts rename to src/condition-trigger/conditions/conditions/template.ts index 578eb1b4..c137b2f8 100644 --- a/src/conditions/conditions/template.ts +++ b/src/condition-trigger/conditions/conditions/template.ts @@ -1,4 +1,5 @@ import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { isTemplateTrue } from './is-template-true'; import { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './types'; export class TemplateConditionEvaluator implements ConditionEvaluator { @@ -14,11 +15,13 @@ export class TemplateConditionEvaluator implements ConditionEvaluator { return { result: !!newState?.hass && - this._context.templateRenderer.renderRecursively( - newState.hass, - this._condition.value_template, - { conditionState: newState }, - ) === true, + isTemplateTrue( + this._context.templateRenderer.renderRecursively( + newState.hass, + this._condition.value_template, + { conditionState: newState }, + ), + ), }; } } diff --git a/src/condition-trigger/conditions/conditions/triggered.ts b/src/condition-trigger/conditions/conditions/triggered.ts new file mode 100644 index 00000000..fbfb7f5f --- /dev/null +++ b/src/condition-trigger/conditions/conditions/triggered.ts @@ -0,0 +1,30 @@ +import { TriggeredBase } from '../../../config/schema/condition-trigger/common/triggered'; +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator } from './types'; + +export class TriggeredConditionEvaluator implements ConditionEvaluator { + private _condition: TriggeredBase; + + constructor(condition: TriggeredBase) { + this._condition = condition; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + const active = newState?.triggered; + const cameraIDs = this._condition.triggered; + const count = active?.size ?? 0; + + let result: boolean; + if (cameraIDs === undefined) { + // Omitted: any camera is triggered. + result = count > 0; + } else if (cameraIDs.length === 0) { + // `[]`: no camera is triggered. + result = count === 0; + } else { + // A list: one of the named cameras is among those triggered. + result = !!active && cameraIDs.some((cameraID) => active.has(cameraID)); + } + return { result }; + } +} diff --git a/src/condition-trigger/conditions/conditions/types.ts b/src/condition-trigger/conditions/conditions/types.ts new file mode 100644 index 00000000..4e382dec --- /dev/null +++ b/src/condition-trigger/conditions/conditions/types.ts @@ -0,0 +1,37 @@ +import { TemplateRenderer } from '../../../card-controller/templates'; +import { Condition } from '../../../config/schema/condition-trigger/conditions/types'; +import { ConditionsEvaluationResult, ConditionState } from '../types'; + +export type ExternalInvalidationUnsubscribeCallback = () => void; + +// A source of change outside the card's `ConditionState` that can invalidate a +// condition's result (currently only `screen`, via `matchMedia`). A condition +// declares its sources so a reactive consumer knows what to watch; a pull +// consumer ignores them. +export interface ExternalInvalidationSource { + subscribe(callback: () => void): ExternalInvalidationUnsubscribeCallback; +} + +/** + * A single condition, constructed once with its configuration and evaluated + * repeatedly against incoming state. + */ +export interface ConditionEvaluator { + evaluate( + newState?: ConditionState, + oldState?: ConditionState, + ): ConditionsEvaluationResult; + + // Sources of change outside `ConditionState` that can invalidate this + // condition's result (currently only `screen`, via `matchMedia`). A reactive + // consumer subscribes to them to know when to re-evaluate; a pull consumer + // ignores them. + externalSources?: ExternalInvalidationSource[]; +} + +export interface EvaluatorContext { + templateRenderer: TemplateRenderer; +} + +// The condition union member(s) carrying a given discriminator literal. +export type ConditionOfType = Extract; diff --git a/src/conditions/conditions/user-agent.ts b/src/condition-trigger/conditions/conditions/user-agent.ts similarity index 89% rename from src/conditions/conditions/user-agent.ts rename to src/condition-trigger/conditions/conditions/user-agent.ts index a8ac6fd7..532ce30e 100644 --- a/src/conditions/conditions/user-agent.ts +++ b/src/condition-trigger/conditions/conditions/user-agent.ts @@ -1,5 +1,5 @@ -import { isBeingCasted } from '../../utils/casting'; -import { isCompanionApp } from '../../utils/companion'; +import { isBeingCasted } from '../../../utils/casting'; +import { isCompanionApp } from '../../../utils/companion'; import { ConditionsEvaluationResult, ConditionState } from '../types'; import { ConditionEvaluator, ConditionOfType } from './types'; diff --git a/src/conditions/conditions/user.ts b/src/condition-trigger/conditions/conditions/user.ts similarity index 82% rename from src/conditions/conditions/user.ts rename to src/condition-trigger/conditions/conditions/user.ts index 7266045a..7bc3b232 100644 --- a/src/conditions/conditions/user.ts +++ b/src/condition-trigger/conditions/conditions/user.ts @@ -11,7 +11,8 @@ export class UserConditionEvaluator implements ConditionEvaluator { public evaluate(newState?: ConditionState): ConditionsEvaluationResult { return { result: - !!newState?.hass?.user && this._condition.users.includes(newState.hass.user.id), + !!newState?.hass?.user && + !!this._condition.users?.includes(newState.hass.user.id), }; } } diff --git a/src/condition-trigger/conditions/conditions/view.ts b/src/condition-trigger/conditions/conditions/view.ts new file mode 100644 index 00000000..88e69f8f --- /dev/null +++ b/src/condition-trigger/conditions/conditions/view.ts @@ -0,0 +1,20 @@ +import { ViewBase } from '../../../config/schema/condition-trigger/common/view'; +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator } from './types'; + +export class ViewConditionEvaluator implements ConditionEvaluator { + private _condition: ViewBase; + + constructor(condition: ViewBase) { + this._condition = condition; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + const view = newState?.view; + return { + // The condition schema requires `views`; the optional access only guards + // the shared base type, on which it is declared optional. + result: !!view && !!this._condition.views?.includes(view), + }; + } +} diff --git a/src/conditions/factory.ts b/src/condition-trigger/conditions/factory.ts similarity index 51% rename from src/conditions/factory.ts rename to src/condition-trigger/conditions/factory.ts index 88d10d0b..db5925cb 100644 --- a/src/conditions/factory.ts +++ b/src/condition-trigger/conditions/factory.ts @@ -1,8 +1,8 @@ -import { AdvancedCameraCardCondition } from '../config/schema/conditions/types'; +import { Condition } from '../../config/schema/condition-trigger/conditions/types'; +import { Trigger } from '../../config/schema/condition-trigger/triggers/types'; import { AndConditionEvaluator } from './conditions/and'; import { CallConditionEvaluator } from './conditions/call'; import { CameraConditionEvaluator } from './conditions/camera'; -import { ConfigConditionEvaluator } from './conditions/config'; import { DisplayModeConditionEvaluator } from './conditions/display-mode'; import { ExpandConditionEvaluator } from './conditions/expand'; import { FullscreenConditionEvaluator } from './conditions/fullscreen'; @@ -24,13 +24,13 @@ import { UserAgentConditionEvaluator } from './conditions/user-agent'; import { ViewConditionEvaluator } from './conditions/view'; export const createConditionEvaluator = ( - condition: AdvancedCameraCardCondition, + condition: Condition, context: EvaluatorContext, ): ConditionEvaluator => { switch (condition.condition) { case undefined: case 'state': - return new StateConditionEvaluator(condition); + return new StateConditionEvaluator(condition, context); case 'view': return new ViewConditionEvaluator(condition); case 'fullscreen': @@ -40,7 +40,7 @@ export const createConditionEvaluator = ( case 'camera': return new CameraConditionEvaluator(condition); case 'numeric_state': - return new NumericStateConditionEvaluator(condition); + return new NumericStateConditionEvaluator(condition, context); case 'user': return new UserConditionEvaluator(condition); case 'media_loaded': @@ -61,8 +61,6 @@ export const createConditionEvaluator = ( return new KeyConditionEvaluator(condition); case 'user_agent': return new UserAgentConditionEvaluator(condition); - case 'config': - return new ConfigConditionEvaluator(condition); case 'initialized': return new InitializedConditionEvaluator(); case 'template': @@ -81,3 +79,68 @@ export const createConditionEvaluator = ( ); } }; + +// A trigger carries a condition value -- the value its matching condition +// checks against -- when it has a field beyond the discriminator and the +// universal `enabled` (e.g. `fullscreen: true`, `cameras: [...]`). +const triggerHasConditionValue = (trigger: Trigger): boolean => + Object.keys(trigger).some((key) => key !== 'trigger' && key !== 'enabled'); + +// Build the condition evaluator a card trigger checks its changes against, or +// null if it has none -- it then fires on any change of its watched state. A +// trigger has no condition when it carries no value (the any-change form) or +// when it is trigger-only (`config`). Otherwise the trigger and its condition +// share a base schema, so an evaluator (typed on that base) is built directly +// from the trigger -- no discriminator-swap. +export const createConditionEvaluatorForTrigger = ( + trigger: Trigger, +): ConditionEvaluator | null => { + // A valueless trigger has no condition to check against -- it fires on any + // change. This is necessary so a change that would make the matching + // condition *fail* still fires the trigger. + // + // - Scenario: the selected camera changes to no camera selected. + // - Trigger: `camera` with no value (means: fire on any change). + // - As a condition, valueless `camera` means "any camera *is* selected". + // - Without this short-circuit: + // - the condition evaluates false (no camera is selected), so + // - the trigger (incorrectly) does not fire. + // + // It comes down to a valueless *trigger* meaning "any change", while a + // valueless *condition* means "the thing is set" -- and the shared evaluator + // only knows the condition meaning, so this distinction must be made here. + if (!triggerHasConditionValue(trigger)) { + return null; + } + + switch (trigger.trigger) { + case 'call': + return new CallConditionEvaluator(trigger); + case 'camera': + return new CameraConditionEvaluator(trigger); + case 'display_mode': + return new DisplayModeConditionEvaluator(trigger); + case 'expand': + return new ExpandConditionEvaluator(trigger); + case 'fullscreen': + return new FullscreenConditionEvaluator(trigger); + case 'interaction': + return new InteractionConditionEvaluator(trigger); + case 'key': + return new KeyConditionEvaluator(trigger); + case 'microphone': + return new MicrophoneConditionEvaluator(trigger); + case 'media_loaded': + return new MediaLoadedConditionEvaluator(trigger); + case 'view': + return new ViewConditionEvaluator(trigger); + case 'triggered': + return new TriggeredConditionEvaluator(trigger); + case 'config': + return null; + default: + // Stock triggers (`state`/`numeric_state`/`template`) evaluate themselves + // and never reuse a card condition through this path. + return null; + } +}; diff --git a/src/conditions/state-manager-via-event.ts b/src/condition-trigger/conditions/state-manager-via-event.ts similarity index 100% rename from src/conditions/state-manager-via-event.ts rename to src/condition-trigger/conditions/state-manager-via-event.ts diff --git a/src/conditions/state-manager.ts b/src/condition-trigger/conditions/state-manager.ts similarity index 68% rename from src/conditions/state-manager.ts rename to src/condition-trigger/conditions/state-manager.ts index 54fdf63f..6f8e3eb5 100644 --- a/src/conditions/state-manager.ts +++ b/src/condition-trigger/conditions/state-manager.ts @@ -1,4 +1,5 @@ import { isEqual } from 'lodash-es'; +import { SerialRunner } from '../../utils/concurrency/serial-runner'; import { ConditionState, ConditionStateChange, @@ -13,6 +14,11 @@ export class ConditionStateManager implements ConditionStateManagerReadonlyInter private _listeners: ConditionStateListener[] = []; private _state: ConditionState = {}; + // Serializes application so a change made from within a listener (e.g. an + // action that updates the card state) is applied after the in-flight change, + // never nested inside it (which would alter the state mid-dispatch). + private _runner = new SerialRunner(); + public addListener(listener: ConditionStateListener): void { this._listeners.push(listener); } @@ -25,7 +31,14 @@ export class ConditionStateManager implements ConditionStateManagerReadonlyInter return this._state; } - public setState(state: ConditionState): boolean { + // Returns whether the state changed, or `null` if the change was deferred + // (made reentrantly from within a listener) and so its outcome is not yet + // known. + public setState(state: ConditionState): boolean | null { + return this._runner.run(() => this._applyChange(state)); + } + + private _applyChange(state: ConditionState): boolean { const changeState = this._calculateTrueChange(state); if (!Object.keys(changeState).length) { return false; diff --git a/src/condition-trigger/conditions/types.ts b/src/condition-trigger/conditions/types.ts new file mode 100644 index 00000000..963a858e --- /dev/null +++ b/src/condition-trigger/conditions/types.ts @@ -0,0 +1,65 @@ +import { IssuePresence } from '../../card-controller/issues/types'; +import { KeysState, MicrophoneState } from '../../card-controller/types'; +import { AdvancedCameraCardView } from '../../config/schema/common/const'; +import { ViewDisplayMode } from '../../config/schema/common/display'; +import { AdvancedCameraCardConfig } from '../../config/schema/types'; +import { HomeAssistant } from '../../ha/types'; +import { MediaLoadedInfo } from '../../types'; + +export interface ConditionState { + call?: boolean; + camera?: string; + // The engaged substream for the selected camera (absent when the camera's own + // stream is used). + substreamID?: string; + config?: AdvancedCameraCardConfig; + displayMode?: ViewDisplayMode; + expand?: boolean; + fullscreen?: boolean; + initialized?: boolean; + interaction?: boolean; + keys?: KeysState; + mediaLoadedInfo?: MediaLoadedInfo | null; + microphone?: MicrophoneState; + panel?: boolean; + issues?: IssuePresence; + hass?: HomeAssistant; + + // Generic media target identifier. See @view/target-id for details. + targetID?: string; + triggered?: Set; + userAgent?: string; + view?: AdvancedCameraCardView; +} + +export interface ConditionStateChange { + old: ConditionState; + change: ConditionState; + new: ConditionState; +} + +export type ConditionStateListener = (change: ConditionStateChange) => void; + +export interface ConditionStateManagerReadonlyInterface { + addListener(listener: ConditionStateListener): void; + removeListener(listener: ConditionStateListener): void; + getState(): ConditionState; +} + +export interface ConditionsEvaluationResult { + result: boolean; +} + +// The `stateChange` that prompted the evaluation is forwarded so a trigger can +// build its payload from the raw before/after state; condition consumers +// (elements, overrides) simply ignore it. +export type ConditionsListener = ( + result: ConditionsEvaluationResult, + stateChange?: ConditionStateChange, +) => void; + +export interface ConditionsManagerReadonlyInterface { + addListener(listener: ConditionsListener): void; + removeListener(listener: ConditionsListener): void; + getEvaluation(): ConditionsEvaluationResult | null; +} diff --git a/src/condition-trigger/triggers/build-trigger-data.ts b/src/condition-trigger/triggers/build-trigger-data.ts new file mode 100644 index 00000000..f28b0efe --- /dev/null +++ b/src/condition-trigger/triggers/build-trigger-data.ts @@ -0,0 +1,30 @@ +import { TemplateAdvancedCameraCardState } from '../../card-controller/templates/types'; +import { ConditionState, ConditionStateChange } from '../conditions/types'; +import { TriggerData } from './types'; + +// The card-state snapshot (`from_acc`/`to_acc`) surfaced by card triggers: the +// card's own camera/view/config at a point in time. +const getTriggerState = (state: ConditionState): TemplateAdvancedCameraCardState => ({ + ...(state.camera !== undefined && { camera: state.camera }), + ...(state.view !== undefined && { view: state.view }), + ...(state.config !== undefined && { config: state.config }), +}); + +// Assemble the `acc`-platform payload for a card trigger: its `type` plus the +// before/after card-state snapshots (each omitted when it carries nothing). +export const buildCardTriggerData = ( + type: string, + stateChange?: ConditionStateChange, +): TriggerData => { + const data: TriggerData = { platform: 'acc', type }; + if (!stateChange) { + return data; + } + const from = getTriggerState(stateChange.old); + const to = getTriggerState(stateChange.new); + return { + ...data, + ...(Object.keys(from).length && { from_acc: from }), + ...(Object.keys(to).length && { to_acc: to }), + }; +}; diff --git a/src/condition-trigger/triggers/factory.ts b/src/condition-trigger/triggers/factory.ts new file mode 100644 index 00000000..43486b50 --- /dev/null +++ b/src/condition-trigger/triggers/factory.ts @@ -0,0 +1,67 @@ +import { Trigger } from '../../config/schema/condition-trigger/triggers/types'; +import { CallTrigger } from './triggers/call'; +import { CameraTrigger } from './triggers/camera'; +import { ConfigTrigger } from './triggers/config'; +import { DisplayModeTrigger } from './triggers/display-mode'; +import { ExpandTrigger } from './triggers/expand'; +import { FullscreenTrigger } from './triggers/fullscreen'; +import { InitializedTrigger } from './triggers/initialized'; +import { InteractionTrigger } from './triggers/interaction'; +import { KeyTrigger } from './triggers/key'; +import { MediaLoadedTrigger } from './triggers/media-loaded'; +import { MicrophoneTrigger } from './triggers/microphone'; +import { NumericStateTrigger } from './triggers/numeric-state'; +import { ScreenTrigger } from './triggers/screen'; +import { StateTrigger } from './triggers/state'; +import { TemplateTrigger } from './triggers/template'; +import { TriggeredTrigger } from './triggers/triggered'; +import { TriggerEvaluator, TriggerEvaluatorContext } from './triggers/types'; +import { ViewTrigger } from './triggers/view'; + +export const createTriggerEvaluator = ( + trigger: Trigger, + context: TriggerEvaluatorContext, +): TriggerEvaluator => { + switch (trigger.trigger) { + // Stock HA triggers: read `hass.states` and emit HA `State` payloads. + case 'state': + return new StateTrigger(trigger, context); + case 'numeric_state': + return new NumericStateTrigger(trigger, context); + case 'template': + return new TemplateTrigger(trigger, context); + + // `screen` watches window.matchMedia. + case 'screen': + return new ScreenTrigger(trigger); + + // Card-state triggers: watch a field of `ConditionState`, firing on any + // change (omit the value) or when the change passes the matching condition. + case 'call': + return new CallTrigger(trigger, context); + case 'camera': + return new CameraTrigger(trigger, context); + case 'config': + return new ConfigTrigger(trigger, context); + case 'display_mode': + return new DisplayModeTrigger(trigger, context); + case 'expand': + return new ExpandTrigger(trigger, context); + case 'fullscreen': + return new FullscreenTrigger(trigger, context); + case 'initialized': + return new InitializedTrigger(trigger, context); + case 'interaction': + return new InteractionTrigger(trigger, context); + case 'key': + return new KeyTrigger(trigger, context); + case 'media_loaded': + return new MediaLoadedTrigger(trigger, context); + case 'microphone': + return new MicrophoneTrigger(trigger, context); + case 'triggered': + return new TriggeredTrigger(trigger, context); + case 'view': + return new ViewTrigger(trigger, context); + } +}; diff --git a/src/condition-trigger/triggers/manager.ts b/src/condition-trigger/triggers/manager.ts new file mode 100644 index 00000000..82b601ec --- /dev/null +++ b/src/condition-trigger/triggers/manager.ts @@ -0,0 +1,79 @@ +import { TemplateRenderer } from '../../card-controller/templates'; +import { Trigger } from '../../config/schema/condition-trigger/triggers/types'; +import { isEnabled } from '../common/is-enabled'; +import { ConditionStateManagerReadonlyInterface } from '../conditions/types'; +import { createTriggerEvaluator } from './factory'; +import { + TriggerCallback, + TriggerEvaluator, + TriggerEvaluatorContext, +} from './triggers/types'; +import { TriggerData } from './types'; + +// A trigger evaluator paired with its config, so `enabled` can be re-checked +// against the config each time the evaluator triggers. +interface ManagedTrigger { + config: Trigger; + evaluator: TriggerEvaluator; +} + +/** + * Orchestrates an array of triggers (e.g. for one automation) and notifies + * listeners whenever ANY of them triggers (the top-level triggers list is an + * implicit OR). This is the push-based sibling of `ConditionsManager`. + */ +export class TriggersManager { + private _context: TriggerEvaluatorContext; + private _triggers: ManagedTrigger[]; + private _listeners: TriggerCallback[] = []; + + constructor( + triggers: Trigger[], + stateManager: ConditionStateManagerReadonlyInterface, + ) { + this._context = { stateManager, templateRenderer: new TemplateRenderer() }; + this._triggers = triggers.map((config) => ({ + config, + evaluator: createTriggerEvaluator(config, this._context), + })); + + // `enabled` is a live per-trigger gate (re-evaluated each time), UNLIKE + // HA's once-at-attach: a deliberate deviation to allow dynamic triggering. + this._triggers.forEach(({ config, evaluator }) => + evaluator.subscribe((data) => { + if ( + isEnabled( + this._context.templateRenderer, + config.enabled, + this._context.stateManager.getState(), + // Fail closed: with no hass the `enabled` template cannot be + // evaluated, so the trigger does not fire. + false, + ) + ) { + this._callListeners(data); + } + }), + ); + } + + public destroy(): void { + this._triggers.forEach(({ evaluator }) => evaluator.destroy()); + this._triggers = []; + this._listeners = []; + } + + public addListener(listener: TriggerCallback): void { + if (!this._listeners.includes(listener)) { + this._listeners.push(listener); + } + } + + public removeListener(listener: TriggerCallback): void { + this._listeners = this._listeners.filter((l) => l !== listener); + } + + private _callListeners = (data: TriggerData): void => { + this._listeners.forEach((listener) => listener(data)); + }; +} diff --git a/src/condition-trigger/triggers/triggers/call.ts b/src/condition-trigger/triggers/triggers/call.ts new file mode 100644 index 00000000..1ddfaca3 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/call.ts @@ -0,0 +1,9 @@ +import { ConditionState } from '../../conditions/types'; +import { ConditionStateTriggerBase } from './condition-state-base'; +import { TriggerOfType } from './types'; + +export class CallTrigger extends ConditionStateTriggerBase> { + protected _getValue(state: ConditionState): unknown { + return state.call ?? false; + } +} diff --git a/src/condition-trigger/triggers/triggers/camera.ts b/src/condition-trigger/triggers/triggers/camera.ts new file mode 100644 index 00000000..863faba6 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/camera.ts @@ -0,0 +1,11 @@ +import { ConditionState } from '../../conditions/types'; +import { ConditionStateTriggerBase } from './condition-state-base'; +import { TriggerOfType } from './types'; + +// Triggers when the selected camera changes: to one of `cameras` if listed, to +// no camera if `cameras` is `[]`, or on any change if `cameras` is omitted. +export class CameraTrigger extends ConditionStateTriggerBase> { + protected _getValue(state: ConditionState): unknown { + return state.camera; + } +} diff --git a/src/condition-trigger/triggers/triggers/condition-state-base.ts b/src/condition-trigger/triggers/triggers/condition-state-base.ts new file mode 100644 index 00000000..fa59d4c4 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/condition-state-base.ts @@ -0,0 +1,54 @@ +import { isEqual } from 'lodash-es'; +import { Trigger } from '../../../config/schema/condition-trigger/triggers/types'; +import { ConditionEvaluator } from '../../conditions/conditions/types'; +import { createConditionEvaluatorForTrigger } from '../../conditions/factory'; +import { ConditionState, ConditionStateChange } from '../../conditions/types'; +import { buildCardTriggerData } from '../build-trigger-data'; +import { TriggerCallback, TriggerEvaluator, TriggerEvaluatorContext } from './types'; + +// A trigger driven by `ConditionState` changes: subscribe to the state manager, +// fire when the watched value (`_getValue`) changes and the new state passes +// the trigger's condition, and emit the `acc` payload. The condition is the +// matching condition reused as a point-in-time predicate -- so a trigger and +// its condition share one definition of meaning. A trigger with no value (any +// change), or no matching condition (`config`), has no condition to pass. +export abstract class ConditionStateTriggerBase + implements TriggerEvaluator +{ + protected _trigger: T; + protected _context: TriggerEvaluatorContext; + + private _callback: TriggerCallback | null = null; + private _condition: ConditionEvaluator | null; + + constructor(trigger: T, context: TriggerEvaluatorContext) { + this._trigger = trigger; + this._context = context; + + // The condition the trigger checks each change against. + this._condition = createConditionEvaluatorForTrigger(trigger); + } + + public subscribe(callback: TriggerCallback): void { + this._callback = callback; + this._context.stateManager.addListener(this._handler); + } + + public destroy(): void { + this._context.stateManager.removeListener(this._handler); + this._callback = null; + } + + private _handler = (change: ConditionStateChange): void => { + if (isEqual(this._getValue(change.old), this._getValue(change.new))) { + return; + } + if (this._condition && !this._condition.evaluate(change.new).result) { + return; + } + this._callback?.(buildCardTriggerData(this._trigger.trigger, change)); + }; + + // The slice of state this trigger watches; it fires only when this changes. + protected abstract _getValue(state: ConditionState): unknown; +} diff --git a/src/condition-trigger/triggers/triggers/config.ts b/src/condition-trigger/triggers/triggers/config.ts new file mode 100644 index 00000000..cfe05d27 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/config.ts @@ -0,0 +1,21 @@ +import { getConfigValue } from '../../../config/management'; +import { ConditionState } from '../../conditions/types'; +import { ConditionStateTriggerBase } from './condition-state-base'; +import { TriggerOfType } from './types'; + +// Triggers when the card configuration changes. With `paths`, only a change to +// one of those config paths triggers; without `paths`, any config change does. +// `config` is trigger-only (it has no matching condition), so the watched value +// is the whole of its behavior. +export class ConfigTrigger extends ConditionStateTriggerBase> { + protected _getValue(state: ConditionState): unknown { + const config = state.config; + if (!config) { + return null; + } + // The watched value: detecting a change in these path values *is* the + // `paths` filter. Without `paths`, the whole config is watched. + const paths = this._trigger.paths; + return paths?.length ? paths.map((path) => getConfigValue(config, path)) : config; + } +} diff --git a/src/condition-trigger/triggers/triggers/display-mode.ts b/src/condition-trigger/triggers/triggers/display-mode.ts new file mode 100644 index 00000000..193cc101 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/display-mode.ts @@ -0,0 +1,11 @@ +import { ConditionState } from '../../conditions/types'; +import { ConditionStateTriggerBase } from './condition-state-base'; +import { TriggerOfType } from './types'; + +export class DisplayModeTrigger extends ConditionStateTriggerBase< + TriggerOfType<'display_mode'> +> { + protected _getValue(state: ConditionState): unknown { + return state.displayMode; + } +} diff --git a/src/condition-trigger/triggers/triggers/entity-state-base.ts b/src/condition-trigger/triggers/triggers/entity-state-base.ts new file mode 100644 index 00000000..30f16bf2 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/entity-state-base.ts @@ -0,0 +1,128 @@ +import { HassEntity } from 'home-assistant-js-websocket'; +import { isEqual } from 'lodash-es'; +import { arrayify } from '../../../utils/basic'; +import { Timer } from '../../../utils/timer'; +import { renderTimePeriodToSeconds } from '../../common/time-period'; +import { ConditionStateChange } from '../../conditions/types'; +import { + TriggerCallback, + TriggerEvaluator, + TriggerEvaluatorContext, + TriggerOfType, +} from './types'; + +// Shared scaffolding for the stock entity triggers (`state` and +// `numeric_state`): per-`entity`/`entity_id` fan-out, the `for:` hold (one +// Timer per entity), and the HA-faithful trigger payload. Subclasses implement +// only the per-entity decision (`_processEntity`) and their `platform`. +export abstract class EntityStateTriggerBase< + T extends TriggerOfType<'state'> | TriggerOfType<'numeric_state'>, +> implements TriggerEvaluator +{ + protected _trigger: T; + protected _context: TriggerEvaluatorContext; + + private _callback: TriggerCallback | null = null; + + // A `for:` hold per entity: a list can be holding several independently. + private _forTimers = new Map(); + + // The `trigger.platform` value reported in the trigger payload. + protected abstract readonly _platform: string; + + constructor(trigger: T, context: TriggerEvaluatorContext) { + this._trigger = trigger; + this._context = context; + } + + public subscribe(callback: TriggerCallback): void { + this._callback = callback; + this._onSubscribe(); + this._context.stateManager.addListener(this._stateChangehandler); + } + + public destroy(): void { + this._context.stateManager.removeListener(this._stateChangehandler); + this._forTimers.forEach((timer) => timer.stop()); + this._forTimers.clear(); + this._onDestroy(); + this._callback = null; + } + + protected abstract _processEntityChange( + entityID: string, + oldStateObj: HassEntity | undefined, + newStateObj: HassEntity | undefined, + ): void; + + protected _onSubscribe(): void {} + protected _onDestroy(): void {} + + protected _entityIDs(): string[] { + return arrayify(this._trigger.entity_id ?? this._trigger.entity); + } + + // Cancel a pending `for:` hold for an entity that left its matching condition. + protected _cancelForTimer(entityID: string): void { + this._forTimers.get(entityID)?.stop(); + } + + // Trigger immediately, or arm the `for:` hold so it triggers only after the + // condition has held for the configured duration. + protected _callTriggerOrHold( + entityID: string, + oldStateObj?: HassEntity, + newStateObj?: HassEntity, + ): void { + if (!this._trigger.for) { + this._callTrigger(entityID, oldStateObj, newStateObj); + return; + } + const seconds = renderTimePeriodToSeconds( + this._context.templateRenderer, + this._trigger.for, + this._context.stateManager.getState(), + ); + if (seconds !== null) { + this._getForTimer(entityID).start(seconds, () => + this._callTrigger(entityID, oldStateObj, newStateObj), + ); + } + } + + private _stateChangehandler = (change: ConditionStateChange): void => { + for (const entityID of this._entityIDs()) { + const oldStateObj = change.old.hass?.states?.[entityID]; + const newStateObj = change.new.hass?.states?.[entityID]; + + // Only entities whose state object actually changed are candidates. + if (isEqual(oldStateObj, newStateObj)) { + continue; + } + this._processEntityChange(entityID, oldStateObj, newStateObj); + } + }; + + private _getForTimer(entityID: string): Timer { + let timer = this._forTimers.get(entityID); + if (!timer) { + timer = new Timer(); + this._forTimers.set(entityID, timer); + } + return timer; + } + + private _callTrigger( + entityID: string, + oldStateObj?: HassEntity, + newStateObj?: HassEntity, + ): void { + this._callback?.({ + platform: this._platform, + entity_id: entityID, + entity: entityID, + ...(oldStateObj && { from_state: oldStateObj }), + ...(newStateObj && { to_state: newStateObj }), + }); + } +} diff --git a/src/condition-trigger/triggers/triggers/expand.ts b/src/condition-trigger/triggers/triggers/expand.ts new file mode 100644 index 00000000..e8d6086f --- /dev/null +++ b/src/condition-trigger/triggers/triggers/expand.ts @@ -0,0 +1,9 @@ +import { ConditionState } from '../../conditions/types'; +import { ConditionStateTriggerBase } from './condition-state-base'; +import { TriggerOfType } from './types'; + +export class ExpandTrigger extends ConditionStateTriggerBase> { + protected _getValue(state: ConditionState): unknown { + return state.expand; + } +} diff --git a/src/condition-trigger/triggers/triggers/fullscreen.ts b/src/condition-trigger/triggers/triggers/fullscreen.ts new file mode 100644 index 00000000..425d0847 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/fullscreen.ts @@ -0,0 +1,11 @@ +import { ConditionState } from '../../conditions/types'; +import { ConditionStateTriggerBase } from './condition-state-base'; +import { TriggerOfType } from './types'; + +export class FullscreenTrigger extends ConditionStateTriggerBase< + TriggerOfType<'fullscreen'> +> { + protected _getValue(state: ConditionState): unknown { + return state.fullscreen; + } +} diff --git a/src/condition-trigger/triggers/triggers/initialized.ts b/src/condition-trigger/triggers/triggers/initialized.ts new file mode 100644 index 00000000..94e23b29 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/initialized.ts @@ -0,0 +1,11 @@ +import { ConditionState } from '../../conditions/types'; +import { ConditionStateTriggerBase } from './condition-state-base'; +import { TriggerOfType } from './types'; + +export class InitializedTrigger extends ConditionStateTriggerBase< + TriggerOfType<'initialized'> +> { + protected _getValue(state: ConditionState): unknown { + return state.initialized; + } +} diff --git a/src/condition-trigger/triggers/triggers/interaction.ts b/src/condition-trigger/triggers/triggers/interaction.ts new file mode 100644 index 00000000..5301efc0 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/interaction.ts @@ -0,0 +1,11 @@ +import { ConditionState } from '../../conditions/types'; +import { ConditionStateTriggerBase } from './condition-state-base'; +import { TriggerOfType } from './types'; + +export class InteractionTrigger extends ConditionStateTriggerBase< + TriggerOfType<'interaction'> +> { + protected _getValue(state: ConditionState): unknown { + return state.interaction; + } +} diff --git a/src/condition-trigger/triggers/triggers/is-template-true.ts b/src/condition-trigger/triggers/triggers/is-template-true.ts new file mode 100644 index 00000000..c8312fb9 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/is-template-true.ts @@ -0,0 +1,15 @@ +// Whether a rendered template result counts as true for a TRIGGER. Mirrors HA's +// `result_as_boolean`: a non-zero number, or `1`/`true`/`yes`/`on`/`enable` +// (case-insensitive), is truthy -- more permissive than a condition. +export const isTemplateTrue = (value: unknown): boolean => { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value !== 0; + } + if (typeof value === 'string') { + return ['1', 'true', 'yes', 'on', 'enable'].includes(value.toLowerCase()); + } + return false; +}; diff --git a/src/condition-trigger/triggers/triggers/key.ts b/src/condition-trigger/triggers/triggers/key.ts new file mode 100644 index 00000000..66b39e15 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/key.ts @@ -0,0 +1,9 @@ +import { ConditionState } from '../../conditions/types'; +import { ConditionStateTriggerBase } from './condition-state-base'; +import { TriggerOfType } from './types'; + +export class KeyTrigger extends ConditionStateTriggerBase> { + protected _getValue(state: ConditionState): unknown { + return state.keys; + } +} diff --git a/src/condition-trigger/triggers/triggers/media-loaded.ts b/src/condition-trigger/triggers/triggers/media-loaded.ts new file mode 100644 index 00000000..a62c0681 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/media-loaded.ts @@ -0,0 +1,11 @@ +import { ConditionState } from '../../conditions/types'; +import { ConditionStateTriggerBase } from './condition-state-base'; +import { TriggerOfType } from './types'; + +export class MediaLoadedTrigger extends ConditionStateTriggerBase< + TriggerOfType<'media_loaded'> +> { + protected _getValue(state: ConditionState): unknown { + return state.mediaLoadedInfo != null; + } +} diff --git a/src/condition-trigger/triggers/triggers/microphone.ts b/src/condition-trigger/triggers/triggers/microphone.ts new file mode 100644 index 00000000..bb7a5d03 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/microphone.ts @@ -0,0 +1,13 @@ +import { ConditionState } from '../../conditions/types'; +import { ConditionStateTriggerBase } from './condition-state-base'; +import { TriggerOfType } from './types'; + +// Triggers when the microphone mute state changes: to the given value if `muted` +// is set, or on any change if it is omitted. +export class MicrophoneTrigger extends ConditionStateTriggerBase< + TriggerOfType<'microphone'> +> { + protected _getValue(state: ConditionState): unknown { + return state.microphone?.muted; + } +} diff --git a/src/condition-trigger/triggers/triggers/numeric-state.ts b/src/condition-trigger/triggers/triggers/numeric-state.ts new file mode 100644 index 00000000..d51e3dbf --- /dev/null +++ b/src/condition-trigger/triggers/triggers/numeric-state.ts @@ -0,0 +1,79 @@ +import { HassEntity } from 'home-assistant-js-websocket'; +import { matchesNumericState, readNumericStateValue } from '../../common/numeric-state'; +import { ConditionState } from '../../conditions/types'; +import { EntityStateTriggerBase } from './entity-state-base'; +import { TriggerOfType } from './types'; + +// https://www.home-assistant.io/docs/automation/trigger/#numeric-state-trigger +// Faithful to HA's numeric_state trigger +// (homeassistant/components/homeassistant/triggers/numeric_state.py): triggers +// on the *crossing* into the `above`/`below` range, not while merely in it. +// Each entity is "armed" while outside the range and triggers once on the +// transition in, then disarms until it leaves and re-arms. `for:` holds the +// trigger only while the value stays in range. The in-range check is shared +// with the numeric_state condition (`matchesNumericState`). +export class NumericStateTrigger extends EntityStateTriggerBase< + TriggerOfType<'numeric_state'> +> { + protected readonly _platform = 'numeric_state'; + + // Entities currently outside the range, armed to trigger on the next crossing in. + private _armedEntities = new Set(); + + protected _onSubscribe(): void { + // Arm entities that start with a readable value outside the range, matching + // HA: an unreadable entity is not armed, so it does not trigger on its first + // valid in-range reading (only once it has been outside and crossed in). + const state = this._context.stateManager.getState(); + for (const entityID of this._entityIDs()) { + if ( + readNumericStateValue( + entityID, + state, + this._trigger, + this._context.templateRenderer, + ) !== null && + !this._matches(entityID, state) + ) { + this._armedEntities.add(entityID); + } + } + } + + protected _onDestroy(): void { + this._armedEntities.clear(); + } + + private _matches(entityID: string, state: ConditionState): boolean { + return matchesNumericState( + entityID, + state, + this._trigger, + this._context.templateRenderer, + ); + } + + protected _processEntityChange( + entityID: string, + oldStateObj: HassEntity | undefined, + newStateObj: HassEntity | undefined, + ): void { + // During a state-change dispatch the manager's stored state is already the + // new state, so it is what this entity just changed to. + if (!this._matches(entityID, this._context.stateManager.getState())) { + // Outside the range: (re-)arm, and cancel any pending `for:` hold. + this._armedEntities.add(entityID); + this._cancelForTimer(entityID); + return; + } + + // Inside the range but not armed: already in range, so not a crossing. + if (!this._armedEntities.has(entityID)) { + return; + } + + // Crossing into the range: disarm and trigger (or start the `for:` hold). + this._armedEntities.delete(entityID); + this._callTriggerOrHold(entityID, oldStateObj, newStateObj); + } +} diff --git a/src/condition-trigger/triggers/triggers/screen.ts b/src/condition-trigger/triggers/triggers/screen.ts new file mode 100644 index 00000000..59c1f22a --- /dev/null +++ b/src/condition-trigger/triggers/triggers/screen.ts @@ -0,0 +1,48 @@ +import { + MediaQueryWatcher, + MediaQueryWatcherUnsubscribeCallback, +} from '../../common/media-query-watcher'; +import { buildCardTriggerData } from '../build-trigger-data'; +import { TriggerCallback, TriggerEvaluator, TriggerOfType } from './types'; + +// `screen` watches a matchMedia query, whose state lives outside the card's +// `ConditionState`, so it owns a `MediaQueryWatcher` (the same watcher the +// screen condition uses). It fires on the rising edge of the query match -- +// consistent with "value present => fire on change to that value". +export class ScreenTrigger implements TriggerEvaluator { + private _trigger: TriggerOfType<'screen'>; + + private _callback: TriggerCallback | null = null; + private _watcher: MediaQueryWatcher | null = null; + private _unsubscribeCallback: MediaQueryWatcherUnsubscribeCallback | null = null; + private _matched = false; + + constructor(trigger: TriggerOfType<'screen'>) { + this._trigger = trigger; + } + + public subscribe(callback: TriggerCallback): void { + if (!this._trigger.media_query) { + return; + } + this._callback = callback; + this._watcher = new MediaQueryWatcher(this._trigger.media_query); + this._matched = this._watcher.matches(); + this._unsubscribeCallback = this._watcher.subscribe(this._handler); + } + + public destroy(): void { + this._unsubscribeCallback?.(); + this._unsubscribeCallback = null; + this._watcher = null; + this._callback = null; + } + + private _handler = (): void => { + const matched = !!this._watcher?.matches(); + if (matched && !this._matched) { + this._callback?.(buildCardTriggerData(this._trigger.trigger)); + } + this._matched = matched; + }; +} diff --git a/src/condition-trigger/triggers/triggers/state.ts b/src/condition-trigger/triggers/triggers/state.ts new file mode 100644 index 00000000..0ff016d9 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/state.ts @@ -0,0 +1,97 @@ +import { HassEntity } from 'home-assistant-js-websocket'; +import { arrayify } from '../../../utils/basic'; +import { EntityStateTriggerBase } from './entity-state-base'; +import { TriggerOfType } from './types'; + +// https://www.home-assistant.io/docs/automation/trigger/#state-trigger Faithful +// to HA's state trigger +// (homeassistant/components/homeassistant/triggers/state.py): +// per-`entity`/`entity_id`, `from`/`to`/`not_from`/`not_to` matchers (absent or +// `null` matches anything), the `match_all` attribute-triggering rule, +// `attribute`, and a per-entity `for:`. +export class StateTrigger extends EntityStateTriggerBase> { + protected readonly _platform = 'state'; + + // True when any of `from`/`to`/`not_from`/`not_to` is set. A `null` config value + // (e.g. `to: null`) still counts as set: it matches any state value (see + // `_matches`), but its mere presence is what restricts triggering to *real + // state changes*. This is the whole significance of `to: null` vs omitting + // `to`: both match any value, but `to: null` will NOT trigger on attribute-only + // changes (a constraint exists), whereas omitting all four triggers on those + // too. + private _hasStateConstraint(): boolean { + const trigger = this._trigger; + return ( + trigger.from !== undefined || + trigger.not_from !== undefined || + trigger.to !== undefined || + trigger.not_to !== undefined + ); + } + + private _readValue(stateObj?: HassEntity): string | null { + if (!stateObj) { + return null; + } + const attribute = this._trigger.attribute; + if (attribute !== undefined) { + const value = stateObj.attributes?.[attribute]; + return value === undefined || value === null ? null : String(value); + } + return stateObj.state; + } + + // A value matches when it is in the positive set (`from`/`to`), or not in the + // negative set (`not_from`/`not_to`); an absent or `null` constraint matches + // anything. + private _matches( + value: string | null, + positive?: string | string[] | null, + negative?: string | string[] | null, + ): boolean { + if (positive !== undefined && positive !== null) { + return value !== null && arrayify(positive).includes(value); + } + if (negative !== undefined && negative !== null) { + // An absent value (entity missing) is not in the set, so it matches. + return !(value !== null && arrayify(negative).includes(value)); + } + return true; + } + + protected _processEntityChange( + entityID: string, + oldStateObj: HassEntity | undefined, + newStateObj: HassEntity | undefined, + ): void { + const trigger = this._trigger; + const oldValue = this._readValue(oldStateObj); + const newValue = this._readValue(newStateObj); + + // When watching an attribute, ignore changes that don't move it. + if (trigger.attribute !== undefined && oldValue === newValue) { + return; + } + + const hasStateConstraint = this._hasStateConstraint(); + const matches = + this._matches(oldValue, trigger.from, trigger.not_from) && + this._matches(newValue, trigger.to, trigger.not_to) && + // from/to test the values but not that they *differ*, so an attribute-only + // event (value unchanged) can still satisfy them. Require a genuine change + // when a constraint is set; with none, trigger on those too. + !(hasStateConstraint && oldValue === newValue); + + if (!matches) { + // Only a real change of the watched value cancels a pending `for:` hold; + // an attribute-only change (value unchanged) must leave it running, just + // as HA's `for:` keys off the state, not the whole state object. + if (oldValue !== newValue) { + this._cancelForTimer(entityID); + } + return; + } + + this._callTriggerOrHold(entityID, oldStateObj, newStateObj); + } +} diff --git a/src/condition-trigger/triggers/triggers/template.ts b/src/condition-trigger/triggers/triggers/template.ts new file mode 100644 index 00000000..3b61f076 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/template.ts @@ -0,0 +1,85 @@ +import { Timer } from '../../../utils/timer'; +import { renderTimePeriodToSeconds } from '../../common/time-period'; +import { ConditionState, ConditionStateChange } from '../../conditions/types'; +import { isTemplateTrue } from './is-template-true'; +import { + TriggerCallback, + TriggerEvaluator, + TriggerEvaluatorContext, + TriggerOfType, +} from './types'; + +// https://www.home-assistant.io/docs/automation/trigger/#template-trigger +// Triggers when `value_template` renders true having previously been non-true +// (the rising edge); `for:` requires it to stay true for the duration first. +export class TemplateTrigger implements TriggerEvaluator { + private _trigger: TriggerOfType<'template'>; + private _context: TriggerEvaluatorContext; + + private _callback: TriggerCallback | null = null; + private _forTimer = new Timer(); + + private _lastResult = false; + + constructor(trigger: TriggerOfType<'template'>, context: TriggerEvaluatorContext) { + this._trigger = trigger; + this._context = context; + } + + public subscribe(callback: TriggerCallback): void { + this._callback = callback; + + // Establish the baseline without triggering: a template already true at + // subscribe must not trigger (HA triggers only on a transition to true). + this._lastResult = this._render(this._context.stateManager.getState()); + this._context.stateManager.addListener(this._handler); + } + + public destroy(): void { + this._context.stateManager.removeListener(this._handler); + this._forTimer.stop(); + this._callback = null; + } + + private _handler = (change: ConditionStateChange): void => { + const result = this._render(change.new); + const rising = result && !this._lastResult; + this._lastResult = result; + + if (rising) { + const forPeriod = this._trigger.for; + if (!forPeriod) { + this._callTrigger(); + return; + } + const seconds = renderTimePeriodToSeconds( + this._context.templateRenderer, + forPeriod, + change.new, + ); + if (seconds !== null) { + this._forTimer.start(seconds, () => this._callTrigger()); + } + } else if (!result) { + // No longer holds -- cancel any pending `for:` trigger. + this._forTimer.stop(); + } + }; + + private _render(state: ConditionState): boolean { + return ( + !!state.hass && + isTemplateTrue( + this._context.templateRenderer.renderRecursively( + state.hass, + this._trigger.value_template, + { conditionState: state }, + ), + ) + ); + } + + private _callTrigger(): void { + this._callback?.({ platform: 'template' }); + } +} diff --git a/src/condition-trigger/triggers/triggers/triggered.ts b/src/condition-trigger/triggers/triggers/triggered.ts new file mode 100644 index 00000000..42fd5925 --- /dev/null +++ b/src/condition-trigger/triggers/triggers/triggered.ts @@ -0,0 +1,11 @@ +import { ConditionState } from '../../conditions/types'; +import { ConditionStateTriggerBase } from './condition-state-base'; +import { TriggerOfType } from './types'; + +export class TriggeredTrigger extends ConditionStateTriggerBase< + TriggerOfType<'triggered'> +> { + protected _getValue(state: ConditionState): unknown { + return state.triggered; + } +} diff --git a/src/condition-trigger/triggers/triggers/types.ts b/src/condition-trigger/triggers/triggers/types.ts new file mode 100644 index 00000000..13eb72ba --- /dev/null +++ b/src/condition-trigger/triggers/triggers/types.ts @@ -0,0 +1,23 @@ +import { TemplateRenderer } from '../../../card-controller/templates'; +import { Trigger } from '../../../config/schema/condition-trigger/triggers/types'; +import { ConditionStateManagerReadonlyInterface } from '../../conditions/types'; +import { TriggerData } from '../types'; + +export type TriggerCallback = (data: TriggerData) => void; + +export interface TriggerEvaluatorContext { + stateManager: ConditionStateManagerReadonlyInterface; + templateRenderer: TemplateRenderer; +} + +export type TriggerOfType = Extract; + +/** + * A single trigger built from one `triggers:` entry: it watches its source and + * invokes `callback` when its event occurs. The push-based sibling of the + * pull-based `ConditionEvaluator`. + */ +export interface TriggerEvaluator { + subscribe(callback: TriggerCallback): void; + destroy(): void; +} diff --git a/src/condition-trigger/triggers/triggers/view.ts b/src/condition-trigger/triggers/triggers/view.ts new file mode 100644 index 00000000..8ce54bab --- /dev/null +++ b/src/condition-trigger/triggers/triggers/view.ts @@ -0,0 +1,11 @@ +import { ConditionState } from '../../conditions/types'; +import { ConditionStateTriggerBase } from './condition-state-base'; +import { TriggerOfType } from './types'; + +// Triggers when the selected view changes: to one of `views` if listed, or on +// any change if `views` is omitted. +export class ViewTrigger extends ConditionStateTriggerBase> { + protected _getValue(state: ConditionState): unknown { + return state.view; + } +} diff --git a/src/condition-trigger/triggers/types.ts b/src/condition-trigger/triggers/types.ts new file mode 100644 index 00000000..0a0bc82d --- /dev/null +++ b/src/condition-trigger/triggers/types.ts @@ -0,0 +1,21 @@ +import { HassEntity } from 'home-assistant-js-websocket'; +import { TemplateAdvancedCameraCardState } from '../../card-controller/templates/types'; + +// The top-level `trigger` template variable produced each time an evaluator +// triggers. `platform` is the provider -- a real HA platform for stock +// triggers, or `acc` for the card's own triggers (whose specific kind is then +// in `type`, mirroring HA's device-trigger platform/type split). +export interface TriggerData { + platform: string; + type?: string; + + // Stock (HA-faithful) fields: + entity_id?: string; + entity?: string; + from_state?: HassEntity; + to_state?: HassEntity; + + // Card (`acc` platform) fields -- full before/after card-state trigger data: + from_acc?: TemplateAdvancedCameraCardState; + to_acc?: TemplateAdvancedCameraCardState; +} diff --git a/src/conditions/conditions-manager.ts b/src/conditions/conditions-manager.ts deleted file mode 100644 index e10d433c..00000000 --- a/src/conditions/conditions-manager.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { TemplateRenderer } from '../card-controller/templates'; -import { AdvancedCameraCardCondition } from '../config/schema/conditions/types'; -import { ConditionEvaluator } from './conditions/types'; -import { createConditionEvaluator } from './factory'; -import { - ConditionsEvaluationResult, - ConditionsListener, - ConditionsManagerReadonlyInterface, - ConditionStateChange, - ConditionStateManagerReadonlyInterface, - ConditionsTriggerData, -} from './types'; - -/** - * A class to evaluate an array of conditions, and notify listeners when the - * evaluation changes (a change is either the result changing, or the data - * associated with a result). - */ -export class ConditionsManager implements ConditionsManagerReadonlyInterface { - private _stateManager: ConditionStateManagerReadonlyInterface | null; - private _evaluators: ConditionEvaluator[]; - - private _listeners: ConditionsListener[] = []; - private _evaluation: ConditionsEvaluationResult = { result: false }; - - constructor( - conditions: AdvancedCameraCardCondition[], - stateManager?: ConditionStateManagerReadonlyInterface | null, - ) { - const context = { templateRenderer: new TemplateRenderer() }; - this._evaluators = conditions.map((condition) => - createConditionEvaluator(condition, context), - ); - - this._stateManager = stateManager ?? null; - - // Subscribe evaluators that have external change sources (currently - // `screen`), including nested evaluators inside composites. - this._evaluators.forEach((evaluator) => - evaluator.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._evaluators.forEach((evaluator) => evaluator.destroy?.()); - this._evaluators = []; - } - - 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; - let triggerData: ConditionsTriggerData = {}; - - for (const evaluator of this._evaluators) { - const evaluation = evaluator.evaluate(state, options?.stateChange?.old); - if (!evaluation.result) { - result = false; - break; - } - triggerData = { - ...triggerData, - ...evaluation.triggerData, - }; - } - - const evaluation: ConditionsEvaluationResult = result - ? { result, triggerData } - : { result }; - - if ( - evaluation.result !== this._evaluation.result || - (evaluation.triggerData && Object.keys(evaluation.triggerData).length) - ) { - this._evaluation = evaluation; - if (options?.callListeners ?? true) { - this._listeners.forEach( - (listener) => this._evaluation && listener(this._evaluation), - ); - } - } - } -} diff --git a/src/conditions/conditions/and.ts b/src/conditions/conditions/and.ts deleted file mode 100644 index f98c6f77..00000000 --- a/src/conditions/conditions/and.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - ConditionsEvaluationResult, - ConditionState, - ConditionsTriggerData, -} from '../types'; -import { CompositeConditionEvaluator } from './composite'; - -export class AndConditionEvaluator extends CompositeConditionEvaluator { - public evaluate( - newState?: ConditionState, - oldState?: ConditionState, - ): ConditionsEvaluationResult { - let triggerData: ConditionsTriggerData = {}; - for (const child of this._children) { - const evaluation = child.evaluate(newState, oldState); - if (!evaluation.result) { - return { result: false }; - } - triggerData = { - ...triggerData, - ...evaluation.triggerData, - }; - } - return { result: true, triggerData }; - } -} diff --git a/src/conditions/conditions/camera.ts b/src/conditions/conditions/camera.ts deleted file mode 100644 index ea86fc8c..00000000 --- a/src/conditions/conditions/camera.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ConditionsEvaluationResult, ConditionState } from '../types'; -import { ConditionEvaluator, ConditionOfType } from './types'; - -export class CameraConditionEvaluator implements ConditionEvaluator { - private _condition: ConditionOfType<'camera'>; - - constructor(condition: ConditionOfType<'camera'>) { - this._condition = condition; - } - - public evaluate( - newState?: ConditionState, - oldState?: ConditionState, - ): ConditionsEvaluationResult { - const oldCamera = oldState?.camera; - const newCamera = newState?.camera; - - return { - result: - (!!newCamera && !!this._condition.cameras?.includes(newCamera)) || - (newCamera !== oldCamera && !this._condition.cameras?.length), - ...(newCamera !== oldCamera && { - triggerData: { - ...((oldState?.camera || newState?.camera) && { - camera: { - ...(oldState?.camera && { from: oldState?.camera }), - ...(newState?.camera && { to: newState?.camera }), - }, - }), - }, - }), - }; - } -} diff --git a/src/conditions/conditions/config.ts b/src/conditions/conditions/config.ts deleted file mode 100644 index 2cf388de..00000000 --- a/src/conditions/conditions/config.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { getConfigValue } from '../../config/management'; -import { ConditionsEvaluationResult, ConditionState } from '../types'; -import { ConditionEvaluator, ConditionOfType } from './types'; - -export class ConfigConditionEvaluator implements ConditionEvaluator { - private _condition: ConditionOfType<'config'>; - - constructor(condition: ConditionOfType<'config'>) { - this._condition = condition; - } - - public evaluate( - newState?: ConditionState, - oldState?: ConditionState, - ): ConditionsEvaluationResult { - const newConfig = newState?.config; - const oldConfig = oldState?.config; - - return { - result: - !!newConfig && - newConfig !== oldConfig && - (!this._condition.paths?.length || - this._condition.paths.some( - (key) => - getConfigValue(newConfig, key) !== - (oldConfig ? getConfigValue(oldConfig, key) : undefined), - )), - ...(newConfig !== oldConfig && { - triggerData: { - config: { - ...((oldState?.config || newState?.config) && { - ...(oldState?.config && { from: oldState?.config }), - ...(newState?.config && { to: newState?.config }), - }), - }, - }, - }), - }; - } -} diff --git a/src/conditions/conditions/key.ts b/src/conditions/conditions/key.ts deleted file mode 100644 index 13cbfd09..00000000 --- a/src/conditions/conditions/key.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ConditionsEvaluationResult, ConditionState } from '../types'; -import { ConditionEvaluator, ConditionOfType } from './types'; - -export class KeyConditionEvaluator implements ConditionEvaluator { - private _condition: ConditionOfType<'key'>; - - constructor(condition: ConditionOfType<'key'>) { - this._condition = condition; - } - - public evaluate(newState?: ConditionState): ConditionsEvaluationResult { - const condition = this._condition; - return { - result: - !!newState?.keys && - condition.key in newState.keys && - (condition.state ?? 'down') === newState.keys[condition.key].state && - (condition.ctrl === undefined || - condition.ctrl === !!newState.keys[condition.key].ctrl) && - (condition.alt === undefined || - condition.alt === !!newState.keys[condition.key].alt) && - (condition.meta === undefined || - condition.meta === !!newState.keys[condition.key].meta) && - (condition.shift === undefined || - condition.shift === !!newState.keys[condition.key].shift), - }; - } -} diff --git a/src/conditions/conditions/numeric-state.ts b/src/conditions/conditions/numeric-state.ts deleted file mode 100644 index 7b29fd24..00000000 --- a/src/conditions/conditions/numeric-state.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ConditionsEvaluationResult, ConditionState } from '../types'; -import { ConditionEvaluator, ConditionOfType } from './types'; - -export class NumericStateConditionEvaluator implements ConditionEvaluator { - private _condition: ConditionOfType<'numeric_state'>; - - constructor(condition: ConditionOfType<'numeric_state'>) { - this._condition = condition; - } - - public evaluate(newState?: ConditionState): ConditionsEvaluationResult { - const condition = this._condition; - return { - result: - !!newState?.hass?.states && - condition.entity in newState.hass?.states && - newState.hass.states[condition.entity].state !== undefined && - (condition.above === undefined || - Number(newState.hass.states[condition.entity].state) > condition.above) && - (condition.below === undefined || - Number(newState.hass.states[condition.entity].state) < condition.below), - }; - } -} diff --git a/src/conditions/conditions/screen.ts b/src/conditions/conditions/screen.ts deleted file mode 100644 index 6d8ac176..00000000 --- a/src/conditions/conditions/screen.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ConditionsEvaluationResult } from '../types'; -import { - ConditionEvaluator, - ConditionEvaluatorSubscriptionCallback, - ConditionOfType, -} from './types'; - -export class ScreenConditionEvaluator implements ConditionEvaluator { - private _mediaQuery: MediaQueryList | null = null; - private _onChange: ConditionEvaluatorSubscriptionCallback | null = null; - - private _condition: ConditionOfType<'screen'>; - - constructor(condition: ConditionOfType<'screen'>) { - this._condition = condition; - } - - public evaluate(): ConditionsEvaluationResult { - return { result: window.matchMedia(this._condition.media_query).matches }; - } - - public subscribe(onChange: ConditionEvaluatorSubscriptionCallback): void { - this._onChange = onChange; - this._mediaQuery = window.matchMedia(this._condition.media_query); - this._mediaQuery.addEventListener('change', this._handler); - } - - public destroy(): void { - this._mediaQuery?.removeEventListener('change', this._handler); - this._mediaQuery = null; - } - - private _handler = (): void => this._onChange?.(); -} diff --git a/src/conditions/conditions/state.ts b/src/conditions/conditions/state.ts deleted file mode 100644 index f22ab53d..00000000 --- a/src/conditions/conditions/state.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ConditionsEvaluationResult, ConditionState } from '../types'; -import { ConditionEvaluator, ConditionOfType } from './types'; - -export class StateConditionEvaluator implements ConditionEvaluator { - private _condition: ConditionOfType<'state'>; - - constructor(condition: ConditionOfType<'state'>) { - this._condition = condition; - } - - public evaluate( - newState?: ConditionState, - oldState?: ConditionState, - ): ConditionsEvaluationResult { - const condition = this._condition; - const fromState = oldState?.hass?.states?.[condition.entity]?.state; - const toState = newState?.hass?.states?.[condition.entity]?.state; - - return { - result: - (!condition.state && !condition.state_not && toState !== fromState) || - ((!!condition.state || !!condition.state_not) && - !!toState && - (!condition.state || - (Array.isArray(condition.state) - ? condition.state.includes(toState) - : condition.state === toState)) && - (!condition.state_not || - (Array.isArray(condition.state_not) - ? !condition.state_not.includes(toState) - : condition.state_not !== toState))), - ...(fromState !== toState && { - triggerData: { - state: { - entity: condition.entity, - ...(fromState && { from: fromState }), - ...(toState && { to: toState }), - }, - }, - }), - }; - } -} diff --git a/src/conditions/conditions/triggered.ts b/src/conditions/conditions/triggered.ts deleted file mode 100644 index 6de65644..00000000 --- a/src/conditions/conditions/triggered.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ConditionsEvaluationResult, ConditionState } from '../types'; -import { ConditionEvaluator, ConditionOfType } from './types'; - -export class TriggeredConditionEvaluator implements ConditionEvaluator { - private _condition: ConditionOfType<'triggered'>; - - constructor(condition: ConditionOfType<'triggered'>) { - this._condition = condition; - } - - public evaluate(newState?: ConditionState): ConditionsEvaluationResult { - return { - result: this._condition.triggered.some((triggeredCameraID) => - newState?.triggered?.has(triggeredCameraID), - ), - }; - } -} diff --git a/src/conditions/conditions/types.ts b/src/conditions/conditions/types.ts deleted file mode 100644 index 821ba4f2..00000000 --- a/src/conditions/conditions/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { TemplateRenderer } from '../../card-controller/templates'; -import { AdvancedCameraCardCondition } from '../../config/schema/conditions/types'; -import { ConditionsEvaluationResult, ConditionState } from '../types'; - -export type ConditionEvaluatorSubscriptionCallback = () => void; - -/** - * A single condition, constructed once with its configuration and evaluated - * repeatedly against incoming state. - */ -export interface ConditionEvaluator { - evaluate( - newState?: ConditionState, - oldState?: ConditionState, - ): ConditionsEvaluationResult; - - // Optional hook for conditions with an external change source (e.g. - // `screen`). The owner passes a callback to request re-evaluation. - subscribe?(onChange: ConditionEvaluatorSubscriptionCallback): void; - - destroy?(): void; -} - -export interface EvaluatorContext { - templateRenderer: TemplateRenderer; -} - -// The condition union member(s) carrying a given discriminator literal. -export type ConditionOfType = Extract< - AdvancedCameraCardCondition, - { condition?: T } ->; diff --git a/src/conditions/conditions/view.ts b/src/conditions/conditions/view.ts deleted file mode 100644 index 9de1746e..00000000 --- a/src/conditions/conditions/view.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ConditionsEvaluationResult, ConditionState } from '../types'; -import { ConditionEvaluator, ConditionOfType } from './types'; - -export class ViewConditionEvaluator implements ConditionEvaluator { - private _condition: ConditionOfType<'view'>; - - constructor(condition: ConditionOfType<'view'>) { - this._condition = condition; - } - - public evaluate( - newState?: ConditionState, - oldState?: ConditionState, - ): ConditionsEvaluationResult { - const oldView = oldState?.view; - const newView = newState?.view; - - return { - result: - (!!newView && this._condition.views?.includes(newView)) || - (newView !== oldView && !this._condition.views?.length), - ...(oldView !== newView && { - triggerData: { - ...((oldState?.view || newState?.view) && { - view: { - ...(oldState?.view && { from: oldState.view }), - ...(newState?.view && { to: newState.view }), - }, - }), - }, - }), - }; - } -} diff --git a/src/conditions/types.ts b/src/conditions/types.ts deleted file mode 100644 index 14c18540..00000000 --- a/src/conditions/types.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { IssuePresence } from '../card-controller/issues/types'; -import { KeysState, MicrophoneState } from '../card-controller/types'; -import { AdvancedCameraCardView } from '../config/schema/common/const'; -import { ViewDisplayMode } from '../config/schema/common/display'; -import { AdvancedCameraCardConfig } from '../config/schema/types'; -import { HomeAssistant } from '../ha/types'; -import { MediaLoadedInfo } from '../types'; - -export interface ConditionState { - call?: boolean; - camera?: string; - // The engaged substream for the selected camera (absent when the camera's own - // stream is used). - substreamID?: string; - config?: AdvancedCameraCardConfig; - displayMode?: ViewDisplayMode; - expand?: boolean; - fullscreen?: boolean; - initialized?: boolean; - interaction?: boolean; - keys?: KeysState; - mediaLoadedInfo?: MediaLoadedInfo | null; - microphone?: MicrophoneState; - panel?: boolean; - issues?: IssuePresence; - hass?: HomeAssistant; - - // Generic media target identifier. See @view/target-id for details. - targetID?: string; - triggered?: Set; - userAgent?: string; - view?: AdvancedCameraCardView; -} - -export interface ConditionStateChange { - old: ConditionState; - change: ConditionState; - new: ConditionState; -} - -export type ConditionStateListener = (change: ConditionStateChange) => void; - -export interface ConditionStateManagerReadonlyInterface { - addListener(listener: ConditionStateListener): void; - removeListener(listener: ConditionStateListener): void; - getState(): ConditionState; -} - -interface ConditionsTriggerDataFromTo { - from?: string; - to?: string; -} - -interface ConditionsTriggerDataState extends ConditionsTriggerDataFromTo { - entity: string; -} - -interface ConditionsTriggerDataConfig { - from?: AdvancedCameraCardConfig; - to?: AdvancedCameraCardConfig; -} - -export interface ConditionsTriggerData { - camera?: ConditionsTriggerDataFromTo; - view?: ConditionsTriggerDataFromTo; - state?: ConditionsTriggerDataState; - config?: ConditionsTriggerDataConfig; -} -export interface ConditionsEvaluationResult { - result: boolean; - - // Trigger data is only provided if there was a real change of state (For - // example: if a state condition that matches 'on' previously evaluated to - // true, and a call from the state manager arrives for an unrelated hass state - // update, the condition will still evaluate true, but there won't be any - // trigger data provided since the state relevant to the condition did not - // change). - triggerData?: ConditionsTriggerData; -} - -export type ConditionsListener = (result: ConditionsEvaluationResult) => void; - -export interface ConditionsManagerReadonlyInterface { - addListener(listener: ConditionsListener): void; - removeListener(listener: ConditionsListener): void; - getEvaluation(): ConditionsEvaluationResult | null; -} diff --git a/src/config/management.ts b/src/config/management.ts index 79a919e8..cdff6b9b 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -9,6 +9,7 @@ import { CONF_OVERRIDES, CONF_PROFILES, CONF_STATUS_BAR, + CONF_UPGRADE_FAILURE, CONF_VIEW_DEFAULT_CYCLE_CAMERA, CONF_VIEW_DEFAULT_RESET_ENTITIES, CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS, @@ -21,8 +22,8 @@ import { CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, } from '../const'; -import { arrayify } from '../utils/basic'; -import { AdvancedCameraCardCondition } from './schema/conditions/types'; +import { arrayify, isRecord } from '../utils/basic'; +import { Condition } from './schema/condition-trigger/conditions/types'; import { RawAdvancedCameraCardConfig, RawAdvancedCameraCardConfigArray } from './types'; // ************************************************************************* @@ -362,7 +363,7 @@ const conditionToConditionsTransform = (data: unknown): boolean => { } const oldConditions = data['conditions']; - const newConditions: AdvancedCameraCardCondition[] = []; + const newConditions: Condition[] = []; if (oldConditions['view'] !== undefined) { newConditions.push({ @@ -412,7 +413,7 @@ const conditionToConditionsTransform = (data: unknown): boolean => { state_not: stateCondition['state_not'], }), ...(stateCondition['entity'] && { - entity: stateCondition['entity'], + entity_id: stateCondition['entity'], }), }); } @@ -438,10 +439,551 @@ const conditionToConditionsTransform = (data: unknown): boolean => { return false; }; +const isCompositeCondition = (condition: unknown): boolean => { + if (!isRecord(condition)) { + return false; + } + const kind = condition['condition']; + return typeof kind === 'string' && ['or', 'and', 'not'].includes(kind); +}; + +// Triggers are a flat OR list with no composites, so a composite condition is +// reduced to its leaf conditions for the trigger list (the composite itself is +// retained on the `conditions:` side). +const flattenConditionLeaves = (condition: unknown): unknown[] => { + if (!isCompositeCondition(condition) || !isRecord(condition)) { + return [condition]; + } + const inner = condition['conditions']; + return Array.isArray(inner) ? inner.flatMap(flattenConditionLeaves) : []; +}; + +// A condition that fired on a *change* rather than describing an ongoing state +// was really a trigger (the legacy "conditions-as-triggers" model). Migration +// promotes it to a trigger and drops it from the retained `conditions:`. +// +// Note: `config` is no longer a condition; and although the current schema +// reads a valueless `camera` as "any camera selected", a *legacy* valueless +// `camera` meant the change, so it is still trigger-only here). +const isTriggerOnlyCondition = (condition: unknown): boolean => { + if (!isRecord(condition)) { + return false; + } + const kind = condition['condition']; + if (kind === 'config') { + // `config` is no longer a condition at all. + return true; + } + if (kind === 'camera') { + return !(Array.isArray(condition['cameras']) && condition['cameras'].length); + } + if (kind === 'view') { + return !(Array.isArray(condition['views']) && condition['views'].length); + } + if (kind === 'state' || kind === undefined) { + return condition['state'] === undefined && condition['state_not'] === undefined; + } + return false; +}; + +// Drop trigger-only conditions from a retained `conditions:` list, recursing +// into composites and discarding any that become empty. +const dropTriggerOnlyConditions = (conditions: unknown[]): unknown[] => { + const kept: unknown[] = []; + for (const condition of conditions) { + if ( + isCompositeCondition(condition) && + typeof condition === 'object' && + condition && + Array.isArray(condition['conditions']) + ) { + const inner = dropTriggerOnlyConditions(condition['conditions']); + if (inner.length) { + kept.push({ ...condition, conditions: inner }); + } + } else if (!isTriggerOnlyCondition(condition)) { + kept.push(condition); + } + } + return kept; +}; + +const rewriteConditionAsTrigger = (condition: unknown): unknown => { + if (!isRecord(condition)) { + return condition; + } + const kind = condition['condition']; + + // A `state` condition maps onto the HA state trigger (`state` -> `to`, + // `state_not` -> `not_to`). A discriminator-less condition is the bare + // picture-element state form -- the only condition that may omit `condition`. + if (kind === 'state' || kind === undefined) { + const entityId = condition['entity_id'] ?? condition['entity']; + return { + trigger: 'state', + ...(entityId !== undefined && { entity_id: entityId }), + ...(condition['state'] !== undefined && { to: condition['state'] }), + ...(condition['state_not'] !== undefined && { not_to: condition['state_not'] }), + }; + } + + // Every other condition -- the stock `numeric_state`/`template` and all the + // card-specific kinds -- shares its field names with the matching trigger + // (only `state` involves internal field renames), so promoting is just a + // discriminator swap. + const rest = { ...condition }; + delete rest['condition']; + return { trigger: kind, ...rest }; +}; + +/** + * Promote an automation's `conditions:` into HA-native `triggers:`. + * + * A single simple condition becomes one trigger and the `conditions:` block is + * dropped. Multiple conditions (or a composite) become one trigger per leaf, + * while the original `conditions:` are retained as an ongoing predicate + * (dual-list) -- minus any trigger-only forms, which would no longer be valid + * conditions. Idempotent: an automation that already has `triggers:` is left + * untouched. + */ +const promoteConditionsToTriggersTransform = (data: unknown): boolean => { + if (!isRecord(data) || 'triggers' in data) { + return false; + } + const conditions = data['conditions']; + if (!Array.isArray(conditions) || !conditions.length) { + return false; + } + + if (conditions.length === 1 && !isCompositeCondition(conditions[0])) { + data['triggers'] = [rewriteConditionAsTrigger(conditions[0])]; + delete data['conditions']; + } else { + data['triggers'] = conditions + .flatMap(flattenConditionLeaves) + .map(rewriteConditionAsTrigger); + const ongoing = dropTriggerOnlyConditions(conditions); + if (ongoing.length) { + data['conditions'] = ongoing; + } else { + delete data['conditions']; + } + } + return true; +}; + +/** + * Whether the upgrade recorded any config it could not faithfully convert, + * under {@link CONF_UPGRADE_FAILURE} (only ever written non-empty). The config + * is not modified. + * @param obj The configuration. + * @returns `true` if any failures remain. + */ +export const hasConfigUpgradeFailures = ( + obj: RawAdvancedCameraCardConfig | null, +): boolean => { + const failures = obj?.[CONF_UPGRADE_FAILURE]; + return isRecord(failures) && Object.keys(failures).length > 0; +}; + +/** + * Record entries the upgrade could not faithfully convert under + * `__UPGRADE_FAILURE__.` (the namespace shadows the main config -- see + * {@link CONF_UPGRADE_FAILURE}), appending to any already recorded there. + * @param data The configuration, modified in place. + * @param path The config path the entries came from (e.g. `automations`). + * @param failures The original entries, recorded untouched. + */ +const addUpgradeFailures = ( + data: RawAdvancedCameraCardConfig, + path: string, + failures: unknown[], +): void => { + const upgradeFailures = isRecord(data[CONF_UPGRADE_FAILURE]) + ? data[CONF_UPGRADE_FAILURE] + : {}; + const existing = upgradeFailures[path]; + upgradeFailures[path] = [...(Array.isArray(existing) ? existing : []), ...failures]; + data[CONF_UPGRADE_FAILURE] = upgradeFailures; +}; + +// `template`/`screen` conditions have no "any change" trigger -- their only +// trigger fires on the rising edge alone (HA's own template/numeric_state +// triggers behave identically, and HA has no `screen` trigger at all). An +// automation resting on one cannot re-fire when it stops matching, so its +// migrated `else` will not run on that falling edge. +const RISING_EDGE_ONLY_CONDITIONS = ['template', 'screen']; + +// Build the "fire on any change" trigger that drives a migrated `if`/`then`/ +// `else` for a single condition leaf, plus whether that trigger only sees the +// rising edge. Returns a null trigger for conditions that cannot change at +// runtime (`user`/`user_agent`), which therefore contribute none. +const synthesizeAnyChangeTrigger = ( + leaf: unknown, +): { trigger: RawAdvancedCameraCardConfig | null; risingEdgeOnly: boolean } => { + if (!isRecord(leaf)) { + return { trigger: null, risingEdgeOnly: false }; + } + const kind = leaf['condition'] ?? 'state'; + + // Static within a session: no runtime change, so no trigger. + if (kind === 'user' || kind === 'user_agent') { + return { trigger: null, risingEdgeOnly: false }; + } + + // Entity-backed: a plain `state` watch (no `to`) fires on every change of the + // entity, so the wrapped `if(state)`/`if(numeric_state)` re-evaluates on both + // edges -- the same trigger a user would hand-write in Home Assistant. + if (kind === 'state' || kind === 'numeric_state') { + const entityId = leaf['entity_id'] ?? leaf['entity']; + if (entityId !== undefined) { + return { + trigger: { trigger: 'state', entity_id: entityId }, + risingEdgeOnly: false, + }; + } + } + + // `config` is trigger-only; its `paths` scope a config-change watch (still any + // change), so they are preserved rather than dropped like a match value. + if (kind === 'config') { + const paths = leaf['paths']; + return { + trigger: { trigger: 'config', ...(paths !== undefined && { paths }) }, + risingEdgeOnly: false, + }; + } + + // Rising-edge-only kinds (and a `numeric_state` with only a `value_template`, + // which has no entity to watch): best-effort reuse of their own trigger. + if ( + (typeof kind === 'string' && RISING_EDGE_ONLY_CONDITIONS.includes(kind)) || + kind === 'numeric_state' + ) { + const rest = { ...leaf }; + delete rest['condition']; + return { trigger: { trigger: kind, ...rest }, risingEdgeOnly: true }; + } + + // Card-state kinds: the valueless trigger fires on any change. + return { trigger: { trigger: kind }, risingEdgeOnly: false }; +}; + +// Synthesize the deduplicated set of "any change" triggers for the condition +// leaves. Conditions that are all static never change after startup, so a +// single `initialized` evaluation is faithful. +const synthesizeAnyChangeTriggers = ( + conditions: unknown[], +): RawAdvancedCameraCardConfig[] => { + const triggers: RawAdvancedCameraCardConfig[] = []; + for (const leaf of conditions.flatMap(flattenConditionLeaves)) { + const { trigger } = synthesizeAnyChangeTrigger(leaf); + if (trigger && !triggers.some((existing) => isEqual(existing, trigger))) { + triggers.push(trigger); + } + } + if (!triggers.length) { + triggers.push({ trigger: 'initialized' }); + } + return triggers; +}; + +// A condition leaf whose only trigger fires on the rising edge (`template`/ +// `screen`, or a `numeric_state` with no entity to watch) cannot drive the +// `else` branch when it stops matching, so such an automation cannot be +// faithfully converted. +const hasRisingEdgeOnlyCondition = (conditions: unknown[]): boolean => + conditions + .flatMap(flattenConditionLeaves) + .some((leaf) => synthesizeAnyChangeTrigger(leaf).risingEdgeOnly); + +/** + * Convert one legacy `actions_not` automation in place to an HA-native + * `if`/`then`/`else` action, or report that it failed to convert. + * + * `{ conditions: C, actions: A, actions_not: B }` becomes `{ triggers: + * , actions: [{ if: C, then: A, else: B }] }`: + * the `if` retains both branches and the synthesized triggers re-evaluate it on + * every change of the conditions. When `C` has no ongoing predicate for the + * `if` to test -- it is absent, or holds only trigger-only conditions (legacy + * change-detectors such as a bare `camera` or a `config` condition) -- the + * `else` branch could never run, so `actions_not` is dropped rather than + * wrapped. Conditions with a rising-edge-only leaf are returned as `'failed'`, + * untouched, because their `else` cannot be reproduced faithfully. Idempotent: + * a converted automation has no `actions_not` left to reconvert. + */ +const convertActionsNotAutomation = ( + automation: RawAdvancedCameraCardConfig, +): 'converted' | 'failed' => { + const conditions = automation['conditions']; + + if (!Array.isArray(conditions) || !conditions.length) { + // No conditions -- `actions_not` could never have run; it is simply dropped. + delete automation['actions_not']; + return 'converted'; + } + + if (hasRisingEdgeOnlyCondition(conditions)) { + return 'failed'; + } + + const actionsNot = automation['actions_not']; + const actions = Array.isArray(automation['actions']) ? automation['actions'] : []; + + automation['triggers'] = synthesizeAnyChangeTriggers(conditions); + delete automation['actions_not']; + + // `conditions` move *into* the `if` below; they must not also remain as a + // top-level ongoing condition, which would block the automation (and so the + // `else` branch) whenever they fail -- exactly the case `else` exists to handle. + delete automation['conditions']; + + // The `if` tests only the ongoing predicates; dropping the trigger-only + // conditions can leave nothing, in which case `else` could never run. + const ongoing = dropTriggerOnlyConditions(conditions); + if (!ongoing.length) { + automation['actions'] = actions; + return 'converted'; + } + + automation['actions'] = [ + { + if: ongoing, + then: actions, + ...(Array.isArray(actionsNot) && { else: actionsNot }), + }, + ]; + return 'converted'; +}; + +/** + * Migrate every legacy `actions_not` automation: convert the faithful ones in + * place to `if`/`then`/`else`, and record the rest -- conditions with a + * rising-edge-only leaf, whose `else` cannot be reproduced -- as failures, + * untouched, under `__UPGRADE_FAILURE__.automations` for the user to migrate by + * hand. Runs before + * the conditions->triggers promotion, which then skips the converted ones (they + * now have `triggers:`) and never sees the failed ones. + */ +const migrateActionsNotTransform = (data: unknown): boolean => { + if (!isRecord(data) || !Array.isArray(data[CONF_AUTOMATIONS])) { + return false; + } + const kept: unknown[] = []; + const failed: unknown[] = []; + let modified = false; + for (const automation of data[CONF_AUTOMATIONS]) { + if (isRecord(automation) && 'actions_not' in automation) { + modified = true; + if (convertActionsNotAutomation(automation) === 'failed') { + failed.push(automation); + continue; + } + } + kept.push(automation); + } + if (!modified) { + return false; + } + data[CONF_AUTOMATIONS] = kept; + if (failed.length) { + addUpgradeFailures(data, CONF_AUTOMATIONS, failed); + } + return true; +}; + +// Picture-element conditional wrapper types (shared with upgradePTZElementsToLive). +const CONDITIONAL_ELEMENT_TYPES = [ + 'conditional', + 'custom:advanced-camera-card-conditional', +]; + +const isConditionalElementType = (type: unknown): boolean => + typeof type === 'string' && CONDITIONAL_ELEMENT_TYPES.includes(type); + +// Strip the trigger-only conditions from a single entry's `conditions:`, in +// place. `keep` is false when stripping emptied a non-empty `conditions:`, +// meaning the entry has no meaningful conditions left and the caller should drop +// it; an already-empty `conditions:` (or an entry with none) is the user's and +// left untouched. +const stripTriggerOnlyConditionsFromEntry = ( + entry: RawAdvancedCameraCardConfig, +): { keep: boolean; modified: boolean } => { + const original = entry['conditions']; + if (!Array.isArray(original)) { + return { keep: true, modified: false }; + } + const stripped = dropTriggerOnlyConditions(original); + if (original.length && !stripped.length) { + return { keep: false, modified: true }; + } + if (!isEqual(stripped, original)) { + entry['conditions'] = stripped; + return { keep: true, modified: true }; + } + return { keep: true, modified: false }; +}; + +// Strip trigger-only conditions from the conditional elements in a picture- +// element tree, recursing into the kept conditionals. Conditional elements +// nest, so this is recursive; overrides are a flat list handled inline by the +// parent transform. +const stripTriggerOnlyConditionsFromElements = ( + elements: RawAdvancedCameraCardConfigArray, +): { elements: RawAdvancedCameraCardConfigArray; modified: boolean } => { + let modified = false; + const kept: RawAdvancedCameraCardConfigArray = []; + for (const element of elements) { + if ( + typeof element === 'object' && + element && + isConditionalElementType(element['type']) + ) { + const { keep, modified: entryModified } = + stripTriggerOnlyConditionsFromEntry(element); + modified = entryModified || modified; + if (!keep) { + continue; + } + if (Array.isArray(element['elements'])) { + const inner = stripTriggerOnlyConditionsFromElements(element['elements']); + modified = inner.modified || modified; + element['elements'] = inner.elements; + } + } + kept.push(element); + } + return { elements: kept, modified }; +}; + +/** + * Drop the now-invalid trigger-only conditions (including the removed `config` + * condition) from the `conditions:` of overrides and conditional elements. An + * entry whose conditions become empty has no meaningful conditions left, so it + * is dropped entirely. Automations are handled by the promote transform. + */ +const stripTriggerOnlyConditionsFromOverridesElementsTransform = ( + data: unknown, +): boolean => { + if (!isRecord(data)) { + return false; + } + let modified = false; + + const overrides = data[CONF_OVERRIDES]; + if (Array.isArray(overrides)) { + let overridesModified = false; + const kept: RawAdvancedCameraCardConfigArray = []; + for (const override of overrides) { + if (typeof override === 'object' && override) { + const { keep, modified: entryModified } = + stripTriggerOnlyConditionsFromEntry(override); + overridesModified = entryModified || overridesModified; + if (!keep) { + continue; + } + } + kept.push(override); + } + if (overridesModified) { + data[CONF_OVERRIDES] = kept; + modified = true; + } + } + + const elements = data[CONF_ELEMENTS]; + if (Array.isArray(elements)) { + const result = stripTriggerOnlyConditionsFromElements(elements); + if (result.modified) { + modified = true; + if (result.elements.length) { + data[CONF_ELEMENTS] = result.elements; + } else { + delete data[CONF_ELEMENTS]; + } + } + } + + return modified; +}; + +// Legacy nested trigger template paths -> the HA-native top-level `trigger.*`. +const TRIGGER_TEMPLATE_PATH_REWRITES: { suffix: string; modern: string }[] = [ + { suffix: 'trigger.state.entity', modern: 'trigger.entity_id' }, + { suffix: 'trigger.state.from', modern: 'trigger.from_state.state' }, + { suffix: 'trigger.state.to', modern: 'trigger.to_state.state' }, + { suffix: 'trigger.camera.from', modern: 'trigger.from_acc.camera' }, + { suffix: 'trigger.camera.to', modern: 'trigger.to_acc.camera' }, + { suffix: 'trigger.view.from', modern: 'trigger.from_acc.view' }, + { suffix: 'trigger.view.to', modern: 'trigger.to_acc.view' }, + { suffix: 'trigger.config.from', modern: 'trigger.from_acc.config' }, + { suffix: 'trigger.config.to', modern: 'trigger.to_acc.config' }, +]; + +// Both the released `acc` alias and the full `advanced_camera_card` namespace are +// migrated. +const TRIGGER_TEMPLATE_PREFIXES = ['acc.', 'advanced_camera_card.']; + +const rewriteTriggerTemplatePaths = (value: string): string => { + let result = value; + for (const prefix of TRIGGER_TEMPLATE_PREFIXES) { + for (const { suffix, modern } of TRIGGER_TEMPLATE_PATH_REWRITES) { + result = result.replaceAll(prefix + suffix, modern); + } + } + return result; +}; + +// Apply a string rewrite to every template-string value of a single object, in +// place. Only touches values containing a nunjucks delimiter (`{{` expression +// or `{%` statement), i.e. template strings. +const rewriteTemplateStrings = ( + data: RawAdvancedCameraCardConfig, + rewrite: (value: string) => string, +): boolean => { + let modified = false; + for (const key of Object.keys(data)) { + const value = data[key]; + if (typeof value === 'string' && (value.includes('{{') || value.includes('{%'))) { + const rewritten = rewrite(value); + if (rewritten !== value) { + data[key] = rewritten; + modified = true; + } + } + } + return modified; +}; + +/** + * Rewrite the legacy nested `acc.trigger.*` / `advanced_camera_card.trigger.*` + * template paths to the top-level `trigger.*` surface, in place on a single + * object's string values. Idempotent (a migrated path matches no legacy + * pattern). + * + * @returns `true` if any value was rewritten. + */ +const migrateTriggerTemplatePathsTransform = ( + data: RawAdvancedCameraCardConfig, +): boolean => rewriteTemplateStrings(data, rewriteTriggerTemplatePaths); + +/** + * Retire the ambient `advanced_camera_card.*` template namespace in favour of its + * shorter `acc` alias (the only spelling the trigger surface uses), rewriting the + * prefix in a single object's string values. Idempotent. + * + * @returns `true` if any value was rewritten. + */ +const migrateAmbientTemplateNamespaceTransform = ( + data: RawAdvancedCameraCardConfig, +): boolean => + rewriteTemplateStrings(data, (value) => + value.replaceAll('advanced_camera_card.', 'acc.'), + ); + const callServiceToPerformActionTransform = (data: unknown): boolean => { if ( - typeof data !== 'object' || - !data || + !isRecord(data) || data['action'] !== 'call-service' || typeof data['service'] !== 'string' ) { @@ -483,8 +1025,7 @@ const serviceDataToDataTransform = (data: unknown): boolean => { const upgradePTZElementsToLive = function (): (data: unknown) => boolean { return function (data: unknown): boolean { if ( - typeof data !== 'object' || - !data || + !isRecord(data) || !(CONF_ELEMENTS in data) || !Array.isArray(data[CONF_ELEMENTS]) ) { @@ -511,8 +1052,7 @@ const upgradePTZElementsToLive = function (): (data: unknown) => boolean { if (element['type'] === 'custom:advanced-camera-card-ptz') { movePTZ(element); } else if ( - (element['type'] === 'conditional' || - element['type'] === 'custom:advanced-camera-card-conditional') && + isConditionalElementType(element['type']) && Array.isArray(element['elements']) ) { const newConditionalElements = processElements(element['elements']); @@ -547,7 +1087,7 @@ const upgradePTZElementsToLive = function (): (data: unknown) => boolean { // See: https://github.com/dermotduffy/advanced-camera-card/issues/2385 // See: https://github.com/AlexxIT/WebRTC/blob/master/custom_components/webrtc/www/webrtc-camera.js const ptzIncorrectDataToWebRTCDataTransform = (data: unknown): unknown => { - if (typeof data !== 'object' || !data) { + if (!isRecord(data)) { return undefined; } let modified = false; @@ -649,7 +1189,7 @@ const ptzActionsToCamerasGlobalTransform = (data: unknown): unknown => { }; const ptzControlSettingsTransform = (data: unknown): unknown => { - if (typeof data !== 'object' || !data) { + if (!isRecord(data)) { return data; } @@ -680,7 +1220,7 @@ const ptzControlSettingsTransform = (data: unknown): unknown => { }; const titleControlTransform = (data: unknown): unknown => { - if (typeof data !== 'object' || !data || typeof data['mode'] !== 'string') { + if (!isRecord(data) || typeof data['mode'] !== 'string') { return null; } if (data['mode'] === 'none') { @@ -801,7 +1341,7 @@ const frigateCardToAdvancedCameraCardTransform = ( * @returns `true` if the node was modified. */ const microphoneConnectedToCallTransform = (data: unknown): boolean => { - if (typeof data !== 'object' || !data || data['condition'] !== 'microphone') { + if (!isRecord(data) || data['condition'] !== 'microphone') { return false; } const connected = data['connected']; @@ -858,7 +1398,7 @@ const substreamActionsUnifyTransform = (data: RawAdvancedCameraCardConfig): bool }; const frigateCardToAdvancedCameraCardStyleTransform = (data: unknown): unknown => { - if (typeof data !== 'object' || !data || Array.isArray(data)) { + if (!isRecord(data) || Array.isArray(data)) { return data; } @@ -885,7 +1425,7 @@ const frigateCardToAdvancedCameraCardStyleTransform = (data: unknown): unknown = // refuse to overwrite it -- but we still drop the legacy `events` (otherwise // it would fail the new schema, which expects objects). const triggersEventsToMediaEventsTransform = (triggers: unknown): unknown => { - if (typeof triggers !== 'object' || !triggers) { + if (!isRecord(triggers)) { return undefined; } const events = triggers['events']; @@ -904,7 +1444,7 @@ const UPGRADES = [ // v5.2.0 -> v6.0.0 (data: unknown): boolean => { return upgradeObjectRecursively(serviceDataToDataTransform)( - typeof data === 'object' && data ? data : {}, + isRecord(data) ? data : {}, ); }, upgradePTZElementsToLive(), @@ -1038,7 +1578,7 @@ const UPGRADES = [ // different. (data: unknown): boolean => { return upgradeObjectRecursively(callServiceToPerformActionTransform)( - typeof data === 'object' && data ? (data as RawAdvancedCameraCardConfig) : {}, + isRecord(data) ? data : {}, ); }, upgradeMoveToWithOverrides('dimensions.max_height', CONF_DIMENSIONS_HEIGHT), @@ -1052,7 +1592,7 @@ const UPGRADES = [ // v7.0.0+ (data: unknown): boolean => { return upgradeObjectRecursively(frigateCardToAdvancedCameraCardTransform)( - typeof data === 'object' && data ? (data as RawAdvancedCameraCardConfig) : {}, + isRecord(data) ? data : {}, ); }, upgradeWithOverrides( @@ -1089,7 +1629,7 @@ const UPGRADES = [ upgradeWithOverrides('ptz', ptzIncorrectDataToWebRTCDataTransform), ), - // microphone.connected → call condition migration. Conditions live under + // microphone.connected -> call condition migration. Conditions live under // overrides, elements, and automations. upgradeArrayOfObjects(CONF_OVERRIDES, (override) => upgradeObjectRecursively(microphoneConnectedToCallTransform)(override), @@ -1110,11 +1650,11 @@ const UPGRADES = [ // automations, view-action handlers, etc.). (data: unknown): boolean => { return upgradeObjectRecursively(substreamActionsUnifyTransform)( - typeof data === 'object' && data ? (data as RawAdvancedCameraCardConfig) : {}, + isRecord(data) ? data : {}, ); }, - // Legacy `triggers.events: string[]` → `triggers.media_events`. Targets the + // Legacy `triggers.events: string[]` -> `triggers.media_events`. Targets the // two known places a camera config lives: `cameras_global` and `cameras[]`. // Mirrors the PTZ rename migration above. upgradeWithOverrides('cameras_global.triggers', triggersEventsToMediaEventsTransform), @@ -1122,4 +1662,31 @@ const UPGRADES = [ CONF_CAMERAS, upgradeWithOverrides('triggers', triggersEventsToMediaEventsTransform), ), + + // Convert `actions_not` automations to an `if`/`then`/`else` action (or record + // the unfaithful ones as failures). Runs before the promotion below, which + // then skips the converted ones (they gain `triggers:`). + migrateActionsNotTransform, + + // Promote automation `conditions:` into HA-native `triggers:`. Runs last so it + // sees conditions in their final, fully-migrated form. + upgradeArrayOfObjects(CONF_AUTOMATIONS, promoteConditionsToTriggersTransform), + + // Drop the now-invalid trigger-only conditions (incl. the removed `config` + // condition) from overrides/elements, dropping any entry left ungated. + stripTriggerOnlyConditionsFromOverridesElementsTransform, + + // Rewrite legacy nested trigger template paths to the top-level `trigger.*`. + (data: unknown): boolean => { + return upgradeObjectRecursively(migrateTriggerTemplatePathsTransform)( + isRecord(data) ? data : {}, + ); + }, + + // Rewrite the retired ambient `advanced_camera_card.*` namespace to `acc.*`. + (data: unknown): boolean => { + return upgradeObjectRecursively(migrateAmbientTemplateNamespaceTransform)( + isRecord(data) ? data : {}, + ); + }, ]; diff --git a/src/config/schema/actions/stock/types.ts b/src/config/schema/actions/stock/types.ts deleted file mode 100644 index 615f07b1..00000000 --- a/src/config/schema/actions/stock/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { z } from 'zod'; -import { callServiceActionSchema } from './call-service'; -import { customActionSchema } from './custom'; -import { moreInfoActionSchema } from './more-info'; -import { navigateActionSchema } from './navigate'; -import { noneActionSchema } from './none'; -import { performActionActionSchema } from './perform-action'; -import { toggleActionSchema } from './toggle'; -import { urlActionSchema } from './url'; - -export const stockActionSchema = z.union([ - callServiceActionSchema, - customActionSchema, - moreInfoActionSchema, - navigateActionSchema, - noneActionSchema, - performActionActionSchema, - toggleActionSchema, - urlActionSchema, -]); diff --git a/src/config/schema/actions/types.ts b/src/config/schema/actions/types.ts index d66149f6..6d2c9e5d 100644 --- a/src/config/schema/actions/types.ts +++ b/src/config/schema/actions/types.ts @@ -1,7 +1,10 @@ import { z } from 'zod'; import { linkSchema } from '../common/link'; +import { preprocessToArray } from '../common/preprocess-to-array'; import { severitySchema } from '../common/severity'; import { statusBarItemBaseSchema } from '../common/status-bar'; +import { Condition, conditionSchema } from '../condition-trigger/conditions/types'; +import { actionBaseSchema } from './base'; import { advancedCameraCardCustomActionsBaseSchema } from './custom/base'; import { callAnswerActionConfigSchema } from './custom/call-answer'; import { callEndActionConfigSchema } from './custom/call-end'; @@ -22,13 +25,20 @@ import { sleepActionConfigSchema } from './custom/sleep'; import { substreamOffActionConfigSchema } from './custom/substream-off'; import { substreamOnActionConfigSchema } from './custom/substream-on'; import { viewActionConfigSchema } from './custom/view'; -import { stockActionSchema } from './stock/types'; +import { callServiceActionSchema } from './stock/call-service'; +import { customActionSchema } from './stock/custom'; +import { moreInfoActionSchema } from './stock/more-info'; +import { navigateActionSchema } from './stock/navigate'; +import { noneActionSchema } from './stock/none'; +import { performActionActionSchema } from './stock/perform-action'; +import { toggleActionSchema } from './stock/toggle'; +import { urlActionSchema } from './stock/url'; // ============================================================================ -// Notification and Status Bar action schemas are co-located here because their -// content schemas reference actionConfigSchema (creating a circular dep). -// Each uses z.lazy + a manual type annotation to break the cycle and preserve -// correct type inference. +// The Notification, Status Bar, and `if`/`then`/`else` action schemas are +// co-located here because their content references actionConfigSchema (creating +// a circular dep). Each uses z.lazy + a manual type annotation to break the +// cycle and preserve correct type inference. // See: https://zod.dev/?id=recursive-types // ============================================================================ @@ -88,9 +98,41 @@ export type AdvancedCameraCardCustomActionConfig = z.infer< typeof advancedCameraCardCustomActionSchema >; +// HA `if`/`then`/`else` script action: unlike most actions this has no `action:` +// key, and is identified by the presence of an `if` key. `then`/`else` reference +// actionConfigSchema recursively, so it uses z.lazy to break the cycle. +export type IfActionConfig = z.infer & { + if: Condition[]; + then: ActionConfig[]; + else?: ActionConfig[]; +}; +const ifActionConfigSchema: z.ZodSchema = actionBaseSchema.extend({ + if: preprocessToArray(conditionSchema.array()), + then: preprocessToArray(z.lazy(() => actionConfigSchema).array()), + else: preprocessToArray(z.lazy(() => actionConfigSchema).array()).optional(), +}); + +// The HA stock actions. Assembled here, rather than in a `stock/` file, because +// it includes the recursive `if` action above (which must live in this module). +const stockActionSchema = z.union([ + callServiceActionSchema, + customActionSchema, + ifActionConfigSchema, + moreInfoActionSchema, + navigateActionSchema, + noneActionSchema, + performActionActionSchema, + toggleActionSchema, + urlActionSchema, +]); + +// The specific custom schemas must come *before* the stock union: the latter +// contains `customActionSchema`, a loose `action: fire-dom-event` catch-all +// that would otherwise match (and shadow) every specific custom action, +// dropping their defaults and validation. export const actionConfigSchema = z.union([ - stockActionSchema, advancedCameraCardCustomActionSchema, + stockActionSchema, ]); export type ActionConfig = z.infer; diff --git a/src/config/schema/automations.ts b/src/config/schema/automations.ts index 62c612a3..fc2e6d4f 100644 --- a/src/config/schema/automations.ts +++ b/src/config/schema/automations.ts @@ -1,20 +1,42 @@ import { z } from 'zod'; +import { isRecord } from '../../utils/basic'; import { actionConfigSchema } from './actions/types'; -import { advancedCameraCardConditionSchema } from './conditions/types'; +import { preprocessToArray } from './common/preprocess-to-array'; +import { conditionSchema } from './condition-trigger/conditions/types'; +import { triggerSchema } from './condition-trigger/triggers/types'; const automationActionsSchema = actionConfigSchema.array(); export type AutomationActions = z.infer; -const automationSchema = z - .object({ - conditions: advancedCameraCardConditionSchema.array(), - actions: automationActionsSchema.optional(), - actions_not: automationActionsSchema.optional(), - }) - .refine( - (data) => data.actions?.length || data.actions_not?.length, - 'Automations must include at least one action', - ); +// Accept Home Assistant's singular `trigger`/`condition`/`action` keys, +// renaming each to the plural form used here (mirroring HA's `cv.renamed`). +// Conservative: a singular key is renamed only when its plural is absent. +const renameSingularKeys = (value: unknown): unknown => { + if (!isRecord(value)) { + return value; + } + const renamed = { ...value }; + for (const [singular, plural] of [ + ['trigger', 'triggers'], + ['condition', 'conditions'], + ['action', 'actions'], + ] as const) { + if (singular in renamed && !(plural in renamed)) { + renamed[plural] = renamed[singular]; + delete renamed[singular]; + } + } + return renamed; +}; + +const automationSchema = z.preprocess( + renameSingularKeys, + z.object({ + triggers: preprocessToArray(triggerSchema.array().min(1)), + conditions: preprocessToArray(conditionSchema.array()).optional(), + actions: preprocessToArray(automationActionsSchema), + }), +); export type Automation = z.infer; export const automationsSchema = automationSchema.array(); diff --git a/src/config/schema/common/preprocess-to-array.ts b/src/config/schema/common/preprocess-to-array.ts new file mode 100644 index 00000000..d96bb4b8 --- /dev/null +++ b/src/config/schema/common/preprocess-to-array.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +// Accept a single item where a list is expected and normalise it to a list, +// mirroring Home Assistant's `cv.ensure_list`. The schema output is always the +// list, so the rest of the code only ever sees the canonical form. +export const preprocessToArray = (arraySchema: T) => + z.preprocess( + (value) => (value === undefined || Array.isArray(value) ? value : [value]), + arraySchema, + ); diff --git a/src/config/schema/common/string-or-array.ts b/src/config/schema/common/string-or-array.ts new file mode 100644 index 00000000..11b035a4 --- /dev/null +++ b/src/config/schema/common/string-or-array.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +// A value that may be a single string or a list of strings — common across HA +// condition/trigger fields (e.g. `state`, `to`, `entity_id`). +export const stringOrArray = z.string().or(z.string().array()); diff --git a/src/config/schema/common/time-period.ts b/src/config/schema/common/time-period.ts new file mode 100644 index 00000000..99607d04 --- /dev/null +++ b/src/config/schema/common/time-period.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +// A Home Assistant time-period value (e.g. a condition/trigger `for:`), +// matching HA's `cv.positive_time_period_template`: a number of seconds, an +// `HH:MM`/ `HH:MM:SS` string, or a {days, hours, minutes, seconds, +// milliseconds} dict. The whole value, or any individual dict field, may be a +// template string rendered at evaluation time (e.g. `minutes: "{{ +// states('input_number.delay') | int }}"`). +// https://www.home-assistant.io/docs/scripts/conditions/ (the `for` option) +const numberOrTemplate = z.number().or(z.string()); +export const timePeriodSchema = z.union([ + z.string(), + z.number(), + z.object({ + days: numberOrTemplate.optional(), + hours: numberOrTemplate.optional(), + minutes: numberOrTemplate.optional(), + seconds: numberOrTemplate.optional(), + milliseconds: numberOrTemplate.optional(), + }), +]); +export type TimePeriod = z.infer; diff --git a/src/config/schema/condition-trigger/common/call.ts b/src/config/schema/condition-trigger/common/call.ts new file mode 100644 index 00000000..b8dfaf3a --- /dev/null +++ b/src/config/schema/condition-trigger/common/call.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const callBaseSchema = z.object({ + call: z.boolean().optional(), +}); +export type CallBase = z.infer; diff --git a/src/config/schema/condition-trigger/common/camera.ts b/src/config/schema/condition-trigger/common/camera.ts new file mode 100644 index 00000000..1e7bdf4b --- /dev/null +++ b/src/config/schema/condition-trigger/common/camera.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const cameraBaseSchema = z.object({ + // Matched against the selected camera: omitted matches any (a camera is + // selected), a list matches one of those cameras, and `[]` matches none (no + // camera selected). + cameras: z.string().array().optional(), +}); +export type CameraBase = z.infer; diff --git a/src/config/schema/condition-trigger/common/config.ts b/src/config/schema/condition-trigger/common/config.ts new file mode 100644 index 00000000..0715717a --- /dev/null +++ b/src/config/schema/condition-trigger/common/config.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const configBaseSchema = z.object({ + paths: z.string().array().optional(), +}); diff --git a/src/config/schema/condition-trigger/common/display-mode.ts b/src/config/schema/condition-trigger/common/display-mode.ts new file mode 100644 index 00000000..41cdbecd --- /dev/null +++ b/src/config/schema/condition-trigger/common/display-mode.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { viewDisplayModeSchema } from '../../common/display'; + +export const displayModeBaseSchema = z.object({ + display_mode: viewDisplayModeSchema.optional(), +}); +export type DisplayModeBase = z.infer; diff --git a/src/config/schema/condition-trigger/common/enabled.ts b/src/config/schema/condition-trigger/common/enabled.ts new file mode 100644 index 00000000..66374b9a --- /dev/null +++ b/src/config/schema/condition-trigger/common/enabled.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +// HA accepts a boolean or a template string (rendered at runtime) for `enabled` +// (`vol.Any(boolean, template)`). Shared by the condition and trigger bases. +export const enabledSchema = z.boolean().or(z.string()).optional(); diff --git a/src/config/schema/condition-trigger/common/expand.ts b/src/config/schema/condition-trigger/common/expand.ts new file mode 100644 index 00000000..27e7c83f --- /dev/null +++ b/src/config/schema/condition-trigger/common/expand.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const expandBaseSchema = z.object({ + expand: z.boolean().optional(), +}); +export type ExpandBase = z.infer; diff --git a/src/config/schema/condition-trigger/common/fullscreen.ts b/src/config/schema/condition-trigger/common/fullscreen.ts new file mode 100644 index 00000000..ecedd31f --- /dev/null +++ b/src/config/schema/condition-trigger/common/fullscreen.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const fullscreenBaseSchema = z.object({ + fullscreen: z.boolean().optional(), +}); +export type FullscreenBase = z.infer; diff --git a/src/config/schema/condition-trigger/common/initialized.ts b/src/config/schema/condition-trigger/common/initialized.ts new file mode 100644 index 00000000..3771ea28 --- /dev/null +++ b/src/config/schema/condition-trigger/common/initialized.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const initializedBaseSchema = z.object({}); diff --git a/src/config/schema/condition-trigger/common/interaction.ts b/src/config/schema/condition-trigger/common/interaction.ts new file mode 100644 index 00000000..9b28a7e0 --- /dev/null +++ b/src/config/schema/condition-trigger/common/interaction.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const interactionBaseSchema = z.object({ + interaction: z.boolean().optional(), +}); +export type InteractionBase = z.infer; diff --git a/src/config/schema/conditions/custom/key.ts b/src/config/schema/condition-trigger/common/key.ts similarity index 62% rename from src/config/schema/conditions/custom/key.ts rename to src/config/schema/condition-trigger/common/key.ts index 2d2100ba..824c97e2 100644 --- a/src/config/schema/conditions/custom/key.ts +++ b/src/config/schema/condition-trigger/common/key.ts @@ -1,11 +1,11 @@ import { z } from 'zod'; -export const keyConditionSchema = z.object({ - condition: z.literal('key'), - key: z.string(), +export const keyBaseSchema = z.object({ + key: z.string().optional(), state: z.enum(['down', 'up']).optional(), ctrl: z.boolean().optional(), shift: z.boolean().optional(), alt: z.boolean().optional(), meta: z.boolean().optional(), }); +export type KeyBase = z.infer; diff --git a/src/config/schema/condition-trigger/common/media-loaded.ts b/src/config/schema/condition-trigger/common/media-loaded.ts new file mode 100644 index 00000000..32fda4df --- /dev/null +++ b/src/config/schema/condition-trigger/common/media-loaded.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const mediaLoadedBaseSchema = z.object({ + media_loaded: z.boolean().optional(), +}); +export type MediaLoadedBase = z.infer; diff --git a/src/config/schema/condition-trigger/common/microphone.ts b/src/config/schema/condition-trigger/common/microphone.ts new file mode 100644 index 00000000..136a4312 --- /dev/null +++ b/src/config/schema/condition-trigger/common/microphone.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const microphoneBaseSchema = z.object({ + muted: z.boolean().optional(), +}); +export type MicrophoneBase = z.infer; diff --git a/src/config/schema/condition-trigger/common/numeric-state.ts b/src/config/schema/condition-trigger/common/numeric-state.ts new file mode 100644 index 00000000..95dcd4b9 --- /dev/null +++ b/src/config/schema/condition-trigger/common/numeric-state.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +// HA `NUMERIC_STATE_THRESHOLD_SCHEMA`: a number, or an entity id whose state +// supplies the threshold value (e.g. compare against an +// input_number/number/sensor/zone in HA). +const thresholdSchema = z.number().or(z.string()); + +// Fields shared by the `numeric_state` condition AND trigger. +// (`entity`/`entity_id` live in the per-context entity bases; the +// ≥1-of-`above`/`below` rule is applied by each consumer, and the trigger adds +// `for`.) +export const numericStateBaseSchema = z.object({ + // Common to both of Home Assistant's dialects (automations & picture + // elements): + above: thresholdSchema.optional(), + below: thresholdSchema.optional(), + attribute: z.string().optional(), + + // HA automation field (not present in the picture-elements dialect), but + // respected in both usecases in this card: + // https://www.home-assistant.io/docs/scripts/conditions/#numeric-state-condition + value_template: z.string().optional(), +}); +export type NumericStateBase = z.infer; + +// HA requires at least one of `above`/`below` on a numeric_state condition or +// trigger. Shared as a predicate because `.shape` (used to merge this base into +// each schema) drops refinements, so the rule is re-applied by each consumer. +export const hasAboveOrBelow = (data: NumericStateBase): boolean => + data.above !== undefined || data.below !== undefined; + +// HA's numeric_state TRIGGER rejects an impossible band where a literal `above` +// exceeds a literal `below`: the value can never be both, so the trigger could +// never fire (HA `validate_above_below`). +export const aboveNotGreaterThanBelow = (data: NumericStateBase): boolean => + typeof data.above !== 'number' || + typeof data.below !== 'number' || + data.above <= data.below; diff --git a/src/config/schema/condition-trigger/common/screen.ts b/src/config/schema/condition-trigger/common/screen.ts new file mode 100644 index 00000000..7dbce002 --- /dev/null +++ b/src/config/schema/condition-trigger/common/screen.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const screenBaseSchema = z.object({ + // Optional, as in HA (a screen condition without a query simply never matches). + media_query: z.string().optional(), +}); +export type ScreenBase = z.infer; diff --git a/src/config/schema/condition-trigger/common/state.ts b/src/config/schema/condition-trigger/common/state.ts new file mode 100644 index 00000000..bc3a83a7 --- /dev/null +++ b/src/config/schema/condition-trigger/common/state.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { timePeriodSchema } from '../../common/time-period'; + +// Fields shared by the `state` condition AND trigger. +export const stateBaseSchema = z.object({ + // Match against an entity attribute instead of its state. + attribute: z.string().optional(), + + // the match must hold for at least this time period. + for: timePeriodSchema.optional(), +}); diff --git a/src/config/schema/condition-trigger/common/triggered.ts b/src/config/schema/condition-trigger/common/triggered.ts new file mode 100644 index 00000000..0676ac19 --- /dev/null +++ b/src/config/schema/condition-trigger/common/triggered.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const triggeredBaseSchema = z.object({ + // Matched against the set of currently-triggered cameras: omitted matches any + // (the set is non-empty), a list matches when one of those cameras is in the + // set, and `[]` matches when the set is empty (no camera triggered). + triggered: z.string().array().optional(), +}); +export type TriggeredBase = z.infer; diff --git a/src/config/schema/conditions/custom/user-agent.ts b/src/config/schema/condition-trigger/common/user-agent.ts similarity index 72% rename from src/config/schema/conditions/custom/user-agent.ts rename to src/config/schema/condition-trigger/common/user-agent.ts index 1bce5013..0458dbb3 100644 --- a/src/config/schema/conditions/custom/user-agent.ts +++ b/src/config/schema/condition-trigger/common/user-agent.ts @@ -1,8 +1,7 @@ import { z } from 'zod'; import { regexSchema } from '../../common/regex'; -export const userAgentConditionSchema = z.object({ - condition: z.literal('user_agent'), +export const userAgentBaseSchema = z.object({ user_agent: z.string().optional(), user_agent_re: regexSchema.optional(), casting: z.boolean().optional(), diff --git a/src/config/schema/condition-trigger/common/user.ts b/src/config/schema/condition-trigger/common/user.ts new file mode 100644 index 00000000..65b76543 --- /dev/null +++ b/src/config/schema/condition-trigger/common/user.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const userBaseSchema = z.object({ + // Optional, as in HA (no users simply matches no one). + users: z.string().array().optional(), +}); diff --git a/src/config/schema/condition-trigger/common/view.ts b/src/config/schema/condition-trigger/common/view.ts new file mode 100644 index 00000000..64bb6257 --- /dev/null +++ b/src/config/schema/condition-trigger/common/view.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const viewBaseSchema = z.object({ + views: z.string().array().optional(), +}); +export type ViewBase = z.infer; diff --git a/src/config/schema/condition-trigger/conditions/base.ts b/src/config/schema/condition-trigger/conditions/base.ts new file mode 100644 index 00000000..8350fc68 --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/base.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { enabledSchema } from '../common/enabled'; + +export const conditionBaseSchema = z.object({ + enabled: enabledSchema, +}); diff --git a/src/config/schema/condition-trigger/conditions/custom/call.ts b/src/config/schema/condition-trigger/conditions/custom/call.ts new file mode 100644 index 00000000..0c076e22 --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/custom/call.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { callBaseSchema } from '../../common/call'; +import { conditionBaseSchema } from '../base'; + +export const callConditionSchema = callBaseSchema + .extend(conditionBaseSchema.shape) + .extend({ + condition: z.literal('call'), + call: z.boolean(), + }); diff --git a/src/config/schema/condition-trigger/conditions/custom/camera.ts b/src/config/schema/condition-trigger/conditions/custom/camera.ts new file mode 100644 index 00000000..5f8b09d5 --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/custom/camera.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { cameraBaseSchema } from '../../common/camera'; +import { conditionBaseSchema } from '../base'; + +export const cameraConditionSchema = cameraBaseSchema + .extend(conditionBaseSchema.shape) + .extend({ + condition: z.literal('camera'), + }); diff --git a/src/config/schema/condition-trigger/conditions/custom/display-mode.ts b/src/config/schema/condition-trigger/conditions/custom/display-mode.ts new file mode 100644 index 00000000..0be8d234 --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/custom/display-mode.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { viewDisplayModeSchema } from '../../../common/display'; +import { displayModeBaseSchema } from '../../common/display-mode'; +import { conditionBaseSchema } from '../base'; + +export const displayModeConditionSchema = displayModeBaseSchema + .extend(conditionBaseSchema.shape) + .extend({ + condition: z.literal('display_mode'), + display_mode: viewDisplayModeSchema, + }); diff --git a/src/config/schema/condition-trigger/conditions/custom/expand.ts b/src/config/schema/condition-trigger/conditions/custom/expand.ts new file mode 100644 index 00000000..3a9975d4 --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/custom/expand.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { expandBaseSchema } from '../../common/expand'; +import { conditionBaseSchema } from '../base'; + +export const expandConditionSchema = expandBaseSchema + .extend(conditionBaseSchema.shape) + .extend({ + condition: z.literal('expand'), + expand: z.boolean(), + }); diff --git a/src/config/schema/condition-trigger/conditions/custom/fullscreen.ts b/src/config/schema/condition-trigger/conditions/custom/fullscreen.ts new file mode 100644 index 00000000..a13157f4 --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/custom/fullscreen.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { fullscreenBaseSchema } from '../../common/fullscreen'; +import { conditionBaseSchema } from '../base'; + +export const fullscreenConditionSchema = fullscreenBaseSchema + .extend(conditionBaseSchema.shape) + .extend({ + condition: z.literal('fullscreen'), + fullscreen: z.boolean(), + }); diff --git a/src/config/schema/condition-trigger/conditions/custom/initialized.ts b/src/config/schema/condition-trigger/conditions/custom/initialized.ts new file mode 100644 index 00000000..aef2b4ef --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/custom/initialized.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { initializedBaseSchema } from '../../common/initialized'; +import { conditionBaseSchema } from '../base'; + +export const initializedConditionSchema = initializedBaseSchema + .extend(conditionBaseSchema.shape) + .extend({ + condition: z.literal('initialized'), + }); diff --git a/src/config/schema/condition-trigger/conditions/custom/interaction.ts b/src/config/schema/condition-trigger/conditions/custom/interaction.ts new file mode 100644 index 00000000..e7d153be --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/custom/interaction.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { interactionBaseSchema } from '../../common/interaction'; +import { conditionBaseSchema } from '../base'; + +export const interactionConditionSchema = interactionBaseSchema + .extend(conditionBaseSchema.shape) + .extend({ + condition: z.literal('interaction'), + interaction: z.boolean(), + }); diff --git a/src/config/schema/condition-trigger/conditions/custom/key.ts b/src/config/schema/condition-trigger/conditions/custom/key.ts new file mode 100644 index 00000000..36ec2fef --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/custom/key.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { keyBaseSchema } from '../../common/key'; +import { conditionBaseSchema } from '../base'; + +export const keyConditionSchema = keyBaseSchema + .extend(conditionBaseSchema.shape) + .extend({ + condition: z.literal('key'), + key: z.string(), + }); diff --git a/src/config/schema/condition-trigger/conditions/custom/media-loaded.ts b/src/config/schema/condition-trigger/conditions/custom/media-loaded.ts new file mode 100644 index 00000000..af36ea88 --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/custom/media-loaded.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { mediaLoadedBaseSchema } from '../../common/media-loaded'; +import { conditionBaseSchema } from '../base'; + +export const mediaLoadedConditionSchema = mediaLoadedBaseSchema + .extend(conditionBaseSchema.shape) + .extend({ + condition: z.literal('media_loaded'), + media_loaded: z.boolean(), + }); diff --git a/src/config/schema/condition-trigger/conditions/custom/microphone.ts b/src/config/schema/condition-trigger/conditions/custom/microphone.ts new file mode 100644 index 00000000..cc5f66d3 --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/custom/microphone.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { microphoneBaseSchema } from '../../common/microphone'; +import { conditionBaseSchema } from '../base'; + +export const microphoneConditionSchema = microphoneBaseSchema + .extend(conditionBaseSchema.shape) + .extend({ + condition: z.literal('microphone'), + muted: z.boolean(), + }); diff --git a/src/config/schema/condition-trigger/conditions/custom/triggered.ts b/src/config/schema/condition-trigger/conditions/custom/triggered.ts new file mode 100644 index 00000000..4f43a4b9 --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/custom/triggered.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { triggeredBaseSchema } from '../../common/triggered'; +import { conditionBaseSchema } from '../base'; + +export const triggeredConditionSchema = triggeredBaseSchema + .extend(conditionBaseSchema.shape) + .extend({ + condition: z.literal('triggered'), + }); diff --git a/src/config/schema/condition-trigger/conditions/custom/user-agent.ts b/src/config/schema/condition-trigger/conditions/custom/user-agent.ts new file mode 100644 index 00000000..92ca8699 --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/custom/user-agent.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; +import { userAgentBaseSchema } from '../../common/user-agent'; +import { conditionBaseSchema } from '../base'; + +export const userAgentConditionSchema = userAgentBaseSchema + .extend(conditionBaseSchema.shape) + .extend({ + condition: z.literal('user_agent'), + }) + // With no field set a `user_agent` condition matches every user agent (always + // true), which is useless; require at least one constraint. + .refine( + (data) => + data.user_agent !== undefined || + data.user_agent_re !== undefined || + data.casting !== undefined || + data.companion !== undefined, + 'A `user_agent` condition requires at least one of `user_agent`/`user_agent_re`/`casting`/`companion`', + ); diff --git a/src/config/schema/condition-trigger/conditions/custom/view.ts b/src/config/schema/condition-trigger/conditions/custom/view.ts new file mode 100644 index 00000000..1abea88e --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/custom/view.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { viewBaseSchema } from '../../common/view'; +import { conditionBaseSchema } from '../base'; + +export const viewConditionSchema = viewBaseSchema + .extend(conditionBaseSchema.shape) + .extend({ + condition: z.literal('view'), + views: z.string().array().min(1), + }); diff --git a/src/config/schema/condition-trigger/conditions/stock/entity-base.ts b/src/config/schema/condition-trigger/conditions/stock/entity-base.ts new file mode 100644 index 00000000..f3494922 --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/stock/entity-base.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; +import { stringOrArray } from '../../../common/string-or-array'; + +// Shared by the stock `state`/`numeric_state` conditions: the card accepts both +// of HA's entity-field dialects (at least one of which is required). Either may +// be a single entity or a list. +export const entityConditionBaseSchema = z + .object({ + // Picture-element / dashboard dialect (canonical in this card): + // https://www.home-assistant.io/dashboards/picture-elements/#conditional-element + entity: stringOrArray.optional(), + + // Automation dialect (accepted alias): + // https://www.home-assistant.io/docs/scripts/conditions/ + entity_id: stringOrArray.optional(), + }) + .refine( + (data) => data.entity !== undefined || data.entity_id !== undefined, + 'A condition requires `entity` (or its `entity_id` alias)', + ); diff --git a/src/config/schema/condition-trigger/conditions/stock/numeric-state.ts b/src/config/schema/condition-trigger/conditions/stock/numeric-state.ts new file mode 100644 index 00000000..0a54087a --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/stock/numeric-state.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import { hasAboveOrBelow, numericStateBaseSchema } from '../../common/numeric-state'; +import { conditionBaseSchema } from '../base'; +import { entityConditionBaseSchema } from './entity-base'; + +// https://www.home-assistant.io/dashboards/conditional/#numeric-state +export const numericStateConditionSchema = entityConditionBaseSchema + .extend(conditionBaseSchema.shape) + .extend(numericStateBaseSchema.shape) + .extend({ condition: z.literal('numeric_state') }) + .refine( + hasAboveOrBelow, + 'A numeric_state condition requires at least one of `above`/`below`', + ); diff --git a/src/config/schema/condition-trigger/conditions/stock/screen.ts b/src/config/schema/condition-trigger/conditions/stock/screen.ts new file mode 100644 index 00000000..713a7add --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/stock/screen.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { screenBaseSchema } from '../../common/screen'; +import { conditionBaseSchema } from '../base'; + +// https://www.home-assistant.io/dashboards/conditional/#screen +export const screenConditionSchema = screenBaseSchema + .extend(conditionBaseSchema.shape) + .extend({ + condition: z.literal('screen'), + }); diff --git a/src/config/schema/condition-trigger/conditions/stock/state.ts b/src/config/schema/condition-trigger/conditions/stock/state.ts new file mode 100644 index 00000000..b05c5771 --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/stock/state.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { stringOrArray } from '../../../common/string-or-array'; +import { stateBaseSchema } from '../../common/state'; +import { conditionBaseSchema } from '../base'; +import { entityConditionBaseSchema } from './entity-base'; + +// https://www.home-assistant.io/dashboards/conditional/#state +export const stateConditionSchema = entityConditionBaseSchema + .extend(conditionBaseSchema.shape) + .extend(stateBaseSchema.shape) + .extend({ + // If `condition` is omitted a state condition is assumed (picture-elements form). + condition: z.literal('state').optional(), + + // Common to both of Home Assistant's condition dialects: + state: stringOrArray.optional(), + + // Only present in HA picture elements dialect (not automation dialect), but + // respected in both usecases in this card. + // https://www.home-assistant.io/dashboards/picture-elements/#conditional-element + state_not: stringOrArray.optional(), + + // How a list of entities is combined: `all` (the default) requires every + // entity to match, `any` requires at least one. + // https://www.home-assistant.io/docs/scripts/conditions/#state-condition + match: z.enum(['all', 'any']).optional(), + }) + // A state condition is not useful without either `state` or `state_not` to + // test against. + .refine( + (data) => data.state !== undefined || data.state_not !== undefined, + 'A `state` condition requires `state` or `state_not`', + ); diff --git a/src/config/schema/conditions/stock/template.ts b/src/config/schema/condition-trigger/conditions/stock/template.ts similarity index 60% rename from src/config/schema/conditions/stock/template.ts rename to src/config/schema/condition-trigger/conditions/stock/template.ts index a4fabf10..f9f3aa18 100644 --- a/src/config/schema/conditions/stock/template.ts +++ b/src/config/schema/condition-trigger/conditions/stock/template.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; +import { conditionBaseSchema } from '../base'; // https://www.home-assistant.io/docs/scripts/conditions/#template-condition -export const templateConditionSchema = z.object({ +export const templateConditionSchema = conditionBaseSchema.extend({ condition: z.literal('template'), value_template: z.string(), }); diff --git a/src/config/schema/conditions/stock/types.ts b/src/config/schema/condition-trigger/conditions/stock/types.ts similarity index 67% rename from src/config/schema/conditions/stock/types.ts rename to src/config/schema/condition-trigger/conditions/stock/types.ts index 6d95b365..efadfc5e 100644 --- a/src/config/schema/conditions/stock/types.ts +++ b/src/config/schema/condition-trigger/conditions/stock/types.ts @@ -1,12 +1,12 @@ import { z } from 'zod'; -import { numericStateConditionSchema } from './numeric'; +import { numericStateConditionSchema } from './numeric-state'; import { screenConditionSchema } from './screen'; import { stateConditionSchema } from './state'; -import { usersConditionSchema } from './users'; +import { userConditionSchema } from './user'; export const stockConditionSchema = z.discriminatedUnion('condition', [ stateConditionSchema, numericStateConditionSchema, screenConditionSchema, - usersConditionSchema, + userConditionSchema, ]); diff --git a/src/config/schema/condition-trigger/conditions/stock/user.ts b/src/config/schema/condition-trigger/conditions/stock/user.ts new file mode 100644 index 00000000..4d4c0058 --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/stock/user.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { userBaseSchema } from '../../common/user'; +import { conditionBaseSchema } from '../base'; + +// https://www.home-assistant.io/dashboards/conditional/#user +export const userConditionSchema = userBaseSchema + .extend(conditionBaseSchema.shape) + .extend({ + condition: z.literal('user'), + }); diff --git a/src/config/schema/condition-trigger/conditions/types.ts b/src/config/schema/condition-trigger/conditions/types.ts new file mode 100644 index 00000000..6f1af37c --- /dev/null +++ b/src/config/schema/condition-trigger/conditions/types.ts @@ -0,0 +1,114 @@ +import { z } from 'zod'; +import { isRecord } from '../../../../utils/basic'; +import { preprocessToArray } from '../../common/preprocess-to-array'; +import { conditionBaseSchema } from './base'; +import { callConditionSchema } from './custom/call'; +import { cameraConditionSchema } from './custom/camera'; +import { displayModeConditionSchema } from './custom/display-mode'; +import { expandConditionSchema } from './custom/expand'; +import { fullscreenConditionSchema } from './custom/fullscreen'; +import { initializedConditionSchema } from './custom/initialized'; +import { interactionConditionSchema } from './custom/interaction'; +import { keyConditionSchema } from './custom/key'; +import { mediaLoadedConditionSchema } from './custom/media-loaded'; +import { microphoneConditionSchema } from './custom/microphone'; +import { triggeredConditionSchema } from './custom/triggered'; +import { userAgentConditionSchema } from './custom/user-agent'; +import { viewConditionSchema } from './custom/view'; +import { numericStateConditionSchema } from './stock/numeric-state'; +import { screenConditionSchema } from './stock/screen'; +import { stateConditionSchema } from './stock/state'; +import { templateConditionSchema } from './stock/template'; +import { userConditionSchema } from './stock/user'; + +type CompositeCondition = z.infer & { + conditions: Condition[]; +}; + +// https://www.home-assistant.io/docs/scripts/conditions/#or-condition +type OrCondition = CompositeCondition & { condition: 'or' }; +const orConditionSchema: z.ZodSchema = conditionBaseSchema.extend({ + condition: z.literal('or'), + + // HA requires the `conditions` key but allows an empty list, and accepts a + // single condition in place of a list (`cv.ensure_list`). + conditions: preprocessToArray(z.lazy(() => conditionSchema).array()), +}); + +// https://www.home-assistant.io/docs/scripts/conditions/#and-condition +type AndCondition = CompositeCondition & { condition: 'and' }; +const andConditionSchema: z.ZodSchema = conditionBaseSchema.extend({ + condition: z.literal('and'), + + // HA requires the `conditions` key but allows an empty list, and accepts a + // single condition in place of a list (`cv.ensure_list`). + conditions: preprocessToArray(z.lazy(() => conditionSchema).array()), +}); + +// https://www.home-assistant.io/docs/scripts/conditions/#not-condition +type NotCondition = CompositeCondition & { condition: 'not' }; +const notConditionSchema: z.ZodSchema = conditionBaseSchema.extend({ + condition: z.literal('not'), + + // HA requires the `conditions` key but allows an empty list, and accepts a + // single condition in place of a list (`cv.ensure_list`). + conditions: preprocessToArray(z.lazy(() => conditionSchema).array()), +}); + +// Expand Home Assistant's composite shorthand to the canonical `{condition: +// , conditions: [...]}` form: +// - `{and|or|not: [...]}` -- the boolean operator is the key. +// - `condition: [...]` -- a list under the discriminator is an implicit AND. +// Conservative: anything already canonical, ambiguous, or unrecognised +// passes through for the union (or its error) to handle. +const expandCompositeShorthand = (value: unknown): unknown => { + if (!isRecord(value)) { + return value; + } + if (Array.isArray(value.condition)) { + const { condition: conditions, ...rest } = value; + return { ...rest, condition: 'and', conditions }; + } + if ('condition' in value) { + return value; + } + const present = (['and', 'or', 'not'] as const).filter((op) => op in value); + const op = present.length === 1 ? present[0] : undefined; + if (op === undefined) { + return value; + } + const { [op]: conditions, ...rest } = value; + return { ...rest, condition: op, conditions }; +}; + +// The raw union of all condition members. `conditionSchema` wraps this with the +// shorthand preprocess; this is exported only for schema introspection. +export const conditionUnion = z.union([ + // Stock conditions: + numericStateConditionSchema, + screenConditionSchema, + stateConditionSchema, + userConditionSchema, + orConditionSchema, + andConditionSchema, + notConditionSchema, + templateConditionSchema, + + // Custom conditions: + callConditionSchema, + cameraConditionSchema, + displayModeConditionSchema, + expandConditionSchema, + fullscreenConditionSchema, + initializedConditionSchema, + interactionConditionSchema, + keyConditionSchema, + mediaLoadedConditionSchema, + microphoneConditionSchema, + triggeredConditionSchema, + userAgentConditionSchema, + viewConditionSchema, +]); + +export const conditionSchema = z.preprocess(expandCompositeShorthand, conditionUnion); +export type Condition = z.infer; diff --git a/src/config/schema/condition-trigger/triggers/base.ts b/src/config/schema/condition-trigger/triggers/base.ts new file mode 100644 index 00000000..6a2227db --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/base.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import { enabledSchema } from '../common/enabled'; + +// Universal trigger fields. Current the card does not support +// `id`/`alias`/`variables` parameters (which HA does). +export const triggerBaseSchema = z.object({ + enabled: enabledSchema, +}); diff --git a/src/config/schema/condition-trigger/triggers/custom/call.ts b/src/config/schema/condition-trigger/triggers/custom/call.ts new file mode 100644 index 00000000..088fea8f --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/custom/call.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { callBaseSchema } from '../../common/call'; +import { triggerBaseSchema } from '../base'; + +export const callTriggerSchema = callBaseSchema + .extend(triggerBaseSchema.shape) + .extend({ trigger: z.literal('call') }); diff --git a/src/config/schema/condition-trigger/triggers/custom/camera.ts b/src/config/schema/condition-trigger/triggers/custom/camera.ts new file mode 100644 index 00000000..aa91e572 --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/custom/camera.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { cameraBaseSchema } from '../../common/camera'; +import { triggerBaseSchema } from '../base'; + +export const cameraTriggerSchema = cameraBaseSchema + .extend(triggerBaseSchema.shape) + .extend({ trigger: z.literal('camera') }); diff --git a/src/config/schema/condition-trigger/triggers/custom/config.ts b/src/config/schema/condition-trigger/triggers/custom/config.ts new file mode 100644 index 00000000..27347607 --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/custom/config.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { configBaseSchema } from '../../common/config'; +import { triggerBaseSchema } from '../base'; + +export const configTriggerSchema = configBaseSchema + .extend(triggerBaseSchema.shape) + .extend({ trigger: z.literal('config') }); diff --git a/src/config/schema/condition-trigger/triggers/custom/display-mode.ts b/src/config/schema/condition-trigger/triggers/custom/display-mode.ts new file mode 100644 index 00000000..71fd7655 --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/custom/display-mode.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { displayModeBaseSchema } from '../../common/display-mode'; +import { triggerBaseSchema } from '../base'; + +export const displayModeTriggerSchema = displayModeBaseSchema + .extend(triggerBaseSchema.shape) + .extend({ trigger: z.literal('display_mode') }); diff --git a/src/config/schema/condition-trigger/triggers/custom/expand.ts b/src/config/schema/condition-trigger/triggers/custom/expand.ts new file mode 100644 index 00000000..eabbab19 --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/custom/expand.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { expandBaseSchema } from '../../common/expand'; +import { triggerBaseSchema } from '../base'; + +export const expandTriggerSchema = expandBaseSchema + .extend(triggerBaseSchema.shape) + .extend({ trigger: z.literal('expand') }); diff --git a/src/config/schema/condition-trigger/triggers/custom/fullscreen.ts b/src/config/schema/condition-trigger/triggers/custom/fullscreen.ts new file mode 100644 index 00000000..5ec014cf --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/custom/fullscreen.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { fullscreenBaseSchema } from '../../common/fullscreen'; +import { triggerBaseSchema } from '../base'; + +export const fullscreenTriggerSchema = fullscreenBaseSchema + .extend(triggerBaseSchema.shape) + .extend({ trigger: z.literal('fullscreen') }); diff --git a/src/config/schema/condition-trigger/triggers/custom/initialized.ts b/src/config/schema/condition-trigger/triggers/custom/initialized.ts new file mode 100644 index 00000000..7d9099a5 --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/custom/initialized.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { initializedBaseSchema } from '../../common/initialized'; +import { triggerBaseSchema } from '../base'; + +export const initializedTriggerSchema = initializedBaseSchema + .extend(triggerBaseSchema.shape) + .extend({ trigger: z.literal('initialized') }); diff --git a/src/config/schema/condition-trigger/triggers/custom/interaction.ts b/src/config/schema/condition-trigger/triggers/custom/interaction.ts new file mode 100644 index 00000000..851395da --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/custom/interaction.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { interactionBaseSchema } from '../../common/interaction'; +import { triggerBaseSchema } from '../base'; + +export const interactionTriggerSchema = interactionBaseSchema + .extend(triggerBaseSchema.shape) + .extend({ trigger: z.literal('interaction') }); diff --git a/src/config/schema/condition-trigger/triggers/custom/key.ts b/src/config/schema/condition-trigger/triggers/custom/key.ts new file mode 100644 index 00000000..19119fa7 --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/custom/key.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { keyBaseSchema } from '../../common/key'; +import { triggerBaseSchema } from '../base'; + +export const keyTriggerSchema = keyBaseSchema + .extend(triggerBaseSchema.shape) + .extend({ trigger: z.literal('key') }); diff --git a/src/config/schema/condition-trigger/triggers/custom/media-loaded.ts b/src/config/schema/condition-trigger/triggers/custom/media-loaded.ts new file mode 100644 index 00000000..926a55fa --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/custom/media-loaded.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { mediaLoadedBaseSchema } from '../../common/media-loaded'; +import { triggerBaseSchema } from '../base'; + +export const mediaLoadedTriggerSchema = mediaLoadedBaseSchema + .extend(triggerBaseSchema.shape) + .extend({ trigger: z.literal('media_loaded') }); diff --git a/src/config/schema/condition-trigger/triggers/custom/microphone.ts b/src/config/schema/condition-trigger/triggers/custom/microphone.ts new file mode 100644 index 00000000..dee2aa44 --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/custom/microphone.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { microphoneBaseSchema } from '../../common/microphone'; +import { triggerBaseSchema } from '../base'; + +export const microphoneTriggerSchema = microphoneBaseSchema + .extend(triggerBaseSchema.shape) + .extend({ trigger: z.literal('microphone') }); diff --git a/src/config/schema/condition-trigger/triggers/custom/screen.ts b/src/config/schema/condition-trigger/triggers/custom/screen.ts new file mode 100644 index 00000000..27287003 --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/custom/screen.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { screenBaseSchema } from '../../common/screen'; +import { triggerBaseSchema } from '../base'; + +export const screenTriggerSchema = screenBaseSchema + .extend(triggerBaseSchema.shape) + .extend({ trigger: z.literal('screen') }); diff --git a/src/config/schema/condition-trigger/triggers/custom/triggered.ts b/src/config/schema/condition-trigger/triggers/custom/triggered.ts new file mode 100644 index 00000000..19b032b4 --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/custom/triggered.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { triggeredBaseSchema } from '../../common/triggered'; +import { triggerBaseSchema } from '../base'; + +export const triggeredTriggerSchema = triggeredBaseSchema + .extend(triggerBaseSchema.shape) + .extend({ trigger: z.literal('triggered') }); diff --git a/src/config/schema/condition-trigger/triggers/custom/view.ts b/src/config/schema/condition-trigger/triggers/custom/view.ts new file mode 100644 index 00000000..4068ec39 --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/custom/view.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { viewBaseSchema } from '../../common/view'; +import { triggerBaseSchema } from '../base'; + +export const viewTriggerSchema = viewBaseSchema + .extend(triggerBaseSchema.shape) + .extend({ trigger: z.literal('view') }); diff --git a/src/config/schema/condition-trigger/triggers/stock/entity-base.ts b/src/config/schema/condition-trigger/triggers/stock/entity-base.ts new file mode 100644 index 00000000..34634704 --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/stock/entity-base.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; +import { stringOrArray } from '../../../common/string-or-array'; + +// Shared by the stock `state`/`numeric_state` conditions: the card accepts both +// of HA's entity-field dialects (at least one of which is required). +export const entityTriggerBaseSchema = z + .object({ + // Automation dialect (canonical for Home Assistant triggers): + // https://www.home-assistant.io/docs/scripts/conditions/ + entity_id: stringOrArray.optional(), + + // Picture element dialect (accepted alias). Allowed since it is canonical + // in Home Assistant picture-elements / dashboard dialect (but not for + // automations). Allowed as a card-specific extension to allow consistent + // users to choose one or the other (or both). + entity: stringOrArray.optional(), + }) + .refine( + (data) => data.entity !== undefined || data.entity_id !== undefined, + 'A trigger requires `entity` (or its `entity_id` alias)', + ); diff --git a/src/config/schema/condition-trigger/triggers/stock/numeric-state.ts b/src/config/schema/condition-trigger/triggers/stock/numeric-state.ts new file mode 100644 index 00000000..c201a110 --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/stock/numeric-state.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import { timePeriodSchema } from '../../../common/time-period'; +import { + aboveNotGreaterThanBelow, + hasAboveOrBelow, + numericStateBaseSchema, +} from '../../common/numeric-state'; +import { triggerBaseSchema } from '../base'; +import { entityTriggerBaseSchema } from './entity-base'; + +// https://www.home-assistant.io/docs/automation/trigger/#numeric-state-trigger +export const numericStateTriggerSchema = entityTriggerBaseSchema + .extend(triggerBaseSchema.shape) + .extend(numericStateBaseSchema.shape) + .extend({ + trigger: z.literal('numeric_state'), + for: timePeriodSchema.optional(), + }) + .refine( + hasAboveOrBelow, + 'A numeric_state trigger requires at least one of `above`/`below`', + ) + .refine( + aboveNotGreaterThanBelow, + 'A numeric_state trigger cannot have `above` greater than `below`', + ); diff --git a/src/config/schema/condition-trigger/triggers/stock/state.ts b/src/config/schema/condition-trigger/triggers/stock/state.ts new file mode 100644 index 00000000..118dfb85 --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/stock/state.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { stateBaseSchema } from '../../common/state'; +import { stringOrArray } from '../../../common/string-or-array'; +import { triggerBaseSchema } from '../base'; +import { entityTriggerBaseSchema } from './entity-base'; + +// https://www.home-assistant.io/docs/automation/trigger/#state-trigger +export const stateTriggerSchema = entityTriggerBaseSchema + .extend(triggerBaseSchema.shape) + .extend(stateBaseSchema.shape) + .extend({ + trigger: z.literal('state'), + + // HA accepts `null` here, distinct from omitting the key: `null` matches + // any state value, but specifying it (vs. omitting all of from/to/not_*) + // restricts firing to real state changes rather than potentially + // attribute-only changes. + from: stringOrArray.nullable().optional(), + to: stringOrArray.nullable().optional(), + not_from: stringOrArray.nullable().optional(), + not_to: stringOrArray.nullable().optional(), + }) + // HA makes `from`/`not_from` and `to`/`not_to` mutually exclusive (vol.Exclusive). + .refine( + (data) => !(data.from !== undefined && data.not_from !== undefined), + '`from` and `not_from` are mutually exclusive', + ) + .refine( + (data) => !(data.to !== undefined && data.not_to !== undefined), + '`to` and `not_to` are mutually exclusive', + ); diff --git a/src/config/schema/condition-trigger/triggers/stock/template.ts b/src/config/schema/condition-trigger/triggers/stock/template.ts new file mode 100644 index 00000000..b0275210 --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/stock/template.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { timePeriodSchema } from '../../../common/time-period'; +import { triggerBaseSchema } from '../base'; + +// https://www.home-assistant.io/docs/automation/trigger/#template-trigger +export const templateTriggerSchema = triggerBaseSchema.extend({ + trigger: z.literal('template'), + value_template: z.string(), + for: timePeriodSchema.optional(), +}); diff --git a/src/config/schema/condition-trigger/triggers/types.ts b/src/config/schema/condition-trigger/triggers/types.ts new file mode 100644 index 00000000..96ee4473 --- /dev/null +++ b/src/config/schema/condition-trigger/triggers/types.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; +import { callTriggerSchema } from './custom/call'; +import { cameraTriggerSchema } from './custom/camera'; +import { configTriggerSchema } from './custom/config'; +import { displayModeTriggerSchema } from './custom/display-mode'; +import { expandTriggerSchema } from './custom/expand'; +import { fullscreenTriggerSchema } from './custom/fullscreen'; +import { initializedTriggerSchema } from './custom/initialized'; +import { interactionTriggerSchema } from './custom/interaction'; +import { keyTriggerSchema } from './custom/key'; +import { mediaLoadedTriggerSchema } from './custom/media-loaded'; +import { microphoneTriggerSchema } from './custom/microphone'; +import { screenTriggerSchema } from './custom/screen'; +import { triggeredTriggerSchema } from './custom/triggered'; +import { viewTriggerSchema } from './custom/view'; +import { numericStateTriggerSchema } from './stock/numeric-state'; +import { stateTriggerSchema } from './stock/state'; +import { templateTriggerSchema } from './stock/template'; + +export const triggerSchema = z.union([ + // Stock triggers (HA automation triggers): + numericStateTriggerSchema, + stateTriggerSchema, + templateTriggerSchema, + + // Custom triggers. Note: `screen` is an HA picture-elements condition with no + // HA trigger, but it genuinely can change (e.g. orientation/resize) -- so it + // is offered as a trigger. + callTriggerSchema, + cameraTriggerSchema, + configTriggerSchema, + displayModeTriggerSchema, + expandTriggerSchema, + fullscreenTriggerSchema, + initializedTriggerSchema, + interactionTriggerSchema, + keyTriggerSchema, + mediaLoadedTriggerSchema, + microphoneTriggerSchema, + screenTriggerSchema, + triggeredTriggerSchema, + viewTriggerSchema, +]); +export type Trigger = z.infer; diff --git a/src/config/schema/conditions/custom/call.ts b/src/config/schema/conditions/custom/call.ts deleted file mode 100644 index 5342e65a..00000000 --- a/src/config/schema/conditions/custom/call.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from 'zod'; - -export const callConditionSchema = z.object({ - condition: z.literal('call'), - call: z.boolean().optional(), -}); diff --git a/src/config/schema/conditions/custom/camera.ts b/src/config/schema/conditions/custom/camera.ts deleted file mode 100644 index b1de86bc..00000000 --- a/src/config/schema/conditions/custom/camera.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from 'zod'; - -export const cameraConditionSchema = z.object({ - condition: z.literal('camera'), - cameras: z.string().array().optional(), -}); diff --git a/src/config/schema/conditions/custom/config.ts b/src/config/schema/conditions/custom/config.ts deleted file mode 100644 index 0db0e5b8..00000000 --- a/src/config/schema/conditions/custom/config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from 'zod'; - -export const configConditionSchema = z.object({ - condition: z.literal('config'), - paths: z.string().array().optional(), -}); diff --git a/src/config/schema/conditions/custom/display-mode.ts b/src/config/schema/conditions/custom/display-mode.ts deleted file mode 100644 index 897545b7..00000000 --- a/src/config/schema/conditions/custom/display-mode.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod'; -import { viewDisplayModeSchema } from '../../common/display'; - -export const displayModeConditionSchema = z.object({ - condition: z.literal('display_mode'), - display_mode: viewDisplayModeSchema, -}); diff --git a/src/config/schema/conditions/custom/expand.ts b/src/config/schema/conditions/custom/expand.ts deleted file mode 100644 index 158d92f4..00000000 --- a/src/config/schema/conditions/custom/expand.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from 'zod'; - -export const expandConditionSchema = z.object({ - condition: z.literal('expand'), - expand: z.boolean(), -}); diff --git a/src/config/schema/conditions/custom/fullscreen.ts b/src/config/schema/conditions/custom/fullscreen.ts deleted file mode 100644 index 5fc9f112..00000000 --- a/src/config/schema/conditions/custom/fullscreen.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from 'zod'; - -export const fullscreenConditionSchema = z.object({ - condition: z.literal('fullscreen'), - fullscreen: z.boolean(), -}); diff --git a/src/config/schema/conditions/custom/initialized.ts b/src/config/schema/conditions/custom/initialized.ts deleted file mode 100644 index 086d8d3f..00000000 --- a/src/config/schema/conditions/custom/initialized.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { z } from 'zod'; - -export const initializedConditionSchema = z.object({ - condition: z.literal('initialized'), -}); diff --git a/src/config/schema/conditions/custom/interaction.ts b/src/config/schema/conditions/custom/interaction.ts deleted file mode 100644 index 77fee551..00000000 --- a/src/config/schema/conditions/custom/interaction.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from 'zod'; - -export const interactionConditionSchema = z.object({ - condition: z.literal('interaction'), - interaction: z.boolean(), -}); diff --git a/src/config/schema/conditions/custom/media-loaded.ts b/src/config/schema/conditions/custom/media-loaded.ts deleted file mode 100644 index aeb4df52..00000000 --- a/src/config/schema/conditions/custom/media-loaded.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from 'zod'; - -export const mediaLoadedConditionSchema = z.object({ - condition: z.literal('media_loaded'), - media_loaded: z.boolean(), -}); diff --git a/src/config/schema/conditions/custom/microphone.ts b/src/config/schema/conditions/custom/microphone.ts deleted file mode 100644 index e0d36e2e..00000000 --- a/src/config/schema/conditions/custom/microphone.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from 'zod'; - -export const microphoneConditionSchema = z.object({ - condition: z.literal('microphone'), - muted: z.boolean(), -}); diff --git a/src/config/schema/conditions/custom/triggered.ts b/src/config/schema/conditions/custom/triggered.ts deleted file mode 100644 index 5ed8364c..00000000 --- a/src/config/schema/conditions/custom/triggered.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from 'zod'; - -export const triggeredConditionSchema = z.object({ - condition: z.literal('triggered'), - triggered: z.string().array(), -}); diff --git a/src/config/schema/conditions/custom/view.ts b/src/config/schema/conditions/custom/view.ts deleted file mode 100644 index 893af791..00000000 --- a/src/config/schema/conditions/custom/view.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from 'zod'; - -export const viewConditionSchema = z.object({ - condition: z.literal('view'), - views: z.string().array().optional(), -}); diff --git a/src/config/schema/conditions/stock/numeric.ts b/src/config/schema/conditions/stock/numeric.ts deleted file mode 100644 index c83e02ff..00000000 --- a/src/config/schema/conditions/stock/numeric.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from 'zod'; - -// https://www.home-assistant.io/dashboards/conditional/#numeric-state -export const numericStateConditionSchema = z.object({ - condition: z.literal('numeric_state'), - entity: z.string(), - above: z.number().optional(), - below: z.number().optional(), -}); diff --git a/src/config/schema/conditions/stock/screen.ts b/src/config/schema/conditions/stock/screen.ts deleted file mode 100644 index a59d10a5..00000000 --- a/src/config/schema/conditions/stock/screen.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod'; - -// https://www.home-assistant.io/dashboards/conditional/#screen -export const screenConditionSchema = z.object({ - condition: z.literal('screen'), - media_query: z.string(), -}); diff --git a/src/config/schema/conditions/stock/state.ts b/src/config/schema/conditions/stock/state.ts deleted file mode 100644 index e3c2a853..00000000 --- a/src/config/schema/conditions/stock/state.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { z } from 'zod'; - -// https://www.home-assistant.io/dashboards/conditional/#state -export const stateConditionSchema = z.object({ - // If the condition is not specified, a state condition is assumed. This - // allows the syntax to match a picture elements conditional: - // https://www.home-assistant.io/dashboards/picture-elements/#conditional-element - condition: z.literal('state').optional(), - entity: z.string(), - state: z.string().or(z.string().array()).optional(), - state_not: z.string().or(z.string().array()).optional(), -}); diff --git a/src/config/schema/conditions/stock/users.ts b/src/config/schema/conditions/stock/users.ts deleted file mode 100644 index 8884ec26..00000000 --- a/src/config/schema/conditions/stock/users.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod'; - -// https://www.home-assistant.io/dashboards/conditional/#user -export const usersConditionSchema = z.object({ - condition: z.literal('user'), - users: z.string().array().min(1), -}); diff --git a/src/config/schema/conditions/types.ts b/src/config/schema/conditions/types.ts deleted file mode 100644 index 16210de8..00000000 --- a/src/config/schema/conditions/types.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { z } from 'zod'; -import { callConditionSchema } from './custom/call'; -import { cameraConditionSchema } from './custom/camera'; -import { configConditionSchema } from './custom/config'; -import { displayModeConditionSchema } from './custom/display-mode'; -import { expandConditionSchema } from './custom/expand'; -import { fullscreenConditionSchema } from './custom/fullscreen'; -import { initializedConditionSchema } from './custom/initialized'; -import { interactionConditionSchema } from './custom/interaction'; -import { keyConditionSchema } from './custom/key'; -import { mediaLoadedConditionSchema } from './custom/media-loaded'; -import { microphoneConditionSchema } from './custom/microphone'; -import { triggeredConditionSchema } from './custom/triggered'; -import { userAgentConditionSchema } from './custom/user-agent'; -import { viewConditionSchema } from './custom/view'; -import { numericStateConditionSchema } from './stock/numeric'; -import { screenConditionSchema } from './stock/screen'; -import { stateConditionSchema } from './stock/state'; -import { templateConditionSchema } from './stock/template'; -import { usersConditionSchema } from './stock/users'; - -// https://www.home-assistant.io/docs/scripts/conditions/#or-condition -type OrCondition = { - condition: 'or'; - conditions: AdvancedCameraCardCondition[]; -}; -const orConditionSchema: z.ZodSchema = z.object({ - condition: z.literal('or'), - conditions: z - .lazy(() => advancedCameraCardConditionSchema) - .array() - .min(1), -}); - -// https://www.home-assistant.io/docs/scripts/conditions/#and-condition -type AndCondition = { - condition: 'and'; - conditions: AdvancedCameraCardCondition[]; -}; -const andConditionSchema: z.ZodSchema = z.object({ - condition: z.literal('and'), - conditions: z - .lazy(() => advancedCameraCardConditionSchema) - .array() - .min(1), -}); - -// https://www.home-assistant.io/docs/scripts/conditions/#not-condition -type NotCondition = { - condition: 'not'; - conditions: AdvancedCameraCardCondition[]; -}; -const notConditionSchema: z.ZodSchema = z.object({ - condition: z.literal('not'), - conditions: z - .lazy(() => advancedCameraCardConditionSchema) - .array() - .min(1), -}); - -export const advancedCameraCardConditionSchema = z.union([ - // Stock conditions: - numericStateConditionSchema, - screenConditionSchema, - stateConditionSchema, - usersConditionSchema, - orConditionSchema, - andConditionSchema, - notConditionSchema, - templateConditionSchema, - - // Custom conditions: - callConditionSchema, - cameraConditionSchema, - configConditionSchema, - displayModeConditionSchema, - expandConditionSchema, - fullscreenConditionSchema, - initializedConditionSchema, - interactionConditionSchema, - keyConditionSchema, - mediaLoadedConditionSchema, - microphoneConditionSchema, - triggeredConditionSchema, - userAgentConditionSchema, - viewConditionSchema, -]); -export type AdvancedCameraCardCondition = z.infer< - typeof advancedCameraCardConditionSchema ->; diff --git a/src/config/schema/elements/types.ts b/src/config/schema/elements/types.ts index b57353ae..3d4b9de2 100644 --- a/src/config/schema/elements/types.ts +++ b/src/config/schema/elements/types.ts @@ -4,8 +4,8 @@ import { statusBarImageItemSchema, statusBarStringItemSchema, } from '../actions/types'; -import { stockConditionSchema } from '../conditions/stock/types'; -import { advancedCameraCardConditionSchema } from '../conditions/types'; +import { stockConditionSchema } from '../condition-trigger/conditions/stock/types'; +import { conditionSchema } from '../condition-trigger/conditions/types'; import { menuIconSchema } from './custom/menu/icon'; import { menuStateIconSchema } from './custom/menu/state-icon'; import { menuSubmenuSchema } from './custom/menu/submenu'; @@ -36,7 +36,7 @@ export const conditionalSchema = z.object({ const advancedCameraCardConditionalSchema = z.object({ type: z.literal('custom:advanced-camera-card-conditional'), - conditions: advancedCameraCardConditionSchema.array(), + conditions: conditionSchema.array(), get elements() { // Recursive schema. diff --git a/src/config/schema/overrides.ts b/src/config/schema/overrides.ts index cecb96a2..ae61d261 100644 --- a/src/config/schema/overrides.ts +++ b/src/config/schema/overrides.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; -import { advancedCameraCardConditionSchema } from './conditions/types'; +import { conditionSchema } from './condition-trigger/conditions/types'; const overrideSchema = z.object({ - conditions: advancedCameraCardConditionSchema.array(), + conditions: conditionSchema.array(), merge: z.looseObject({}).optional(), set: z.looseObject({}).optional(), delete: z.string().array().optional(), diff --git a/src/const.ts b/src/const.ts index 90acac8f..ddf28808 100644 --- a/src/const.ts +++ b/src/const.ts @@ -3,6 +3,8 @@ export const DOCS_URL = 'https://card.camera' as const; export const TROUBLESHOOTING_URL = `${DOCS_URL}/#/troubleshooting` as const; export const TROUBLESHOOTING_CONFIG_UPGRADE_URL = `${TROUBLESHOOTING_URL}?id=configuration-upgrade-available` as const; +export const TROUBLESHOOTING_CONFIG_UPGRADE_FAILURE_URL = + `${TROUBLESHOOTING_URL}?id=configuration-could-not-be-fully-upgraded` as const; export const TROUBLESHOOTING_LEGACY_RESOURCE_URL = `${TROUBLESHOOTING_URL}?id=legacy-dashboard-resource-detected` as const; export const TROUBLESHOOTING_MEDIA_URL = @@ -449,6 +451,12 @@ export const CONF_STATUS_BAR_HEIGHT = `${CONF_STATUS_BAR}.height` as const; export const CONF_STATUS_BAR_ITEMS = `${CONF_STATUS_BAR}.items` as const; +// A single namespace shadowing the main config: anything the upgrade cannot +// faithfully convert is recorded here as a failure, intact, at the same path it +// came from (e.g. top-level `automations` -> `__UPGRADE_FAILURE__.automations`) +// for the user to migrate by hand. +export const CONF_UPGRADE_FAILURE = '__UPGRADE_FAILURE__' as const; + const CONF_DIMENSIONS = 'dimensions' as const; export const CONF_DIMENSIONS_ASPECT_RATIO = `${CONF_DIMENSIONS}.aspect_ratio` as const; export const CONF_DIMENSIONS_ASPECT_RATIO_MODE = diff --git a/src/ha/event-data-match.ts b/src/ha/event-data-match.ts index f0d59dc3..e76dbb6b 100644 --- a/src/ha/event-data-match.ts +++ b/src/ha/event-data-match.ts @@ -1,4 +1,5 @@ import { isMatch } from 'lodash-es'; +import { isRecord } from '../utils/basic'; // Deep subset match between an HA bus event's payload `data` and a user- // configured filter. Mirrors HA automation `event_data` semantics: every key in @@ -8,4 +9,4 @@ import { isMatch } from 'lodash-es'; export const matchesEventData = ( filter: Record, data: unknown, -): boolean => typeof data === 'object' && data !== null && isMatch(data, filter); +): boolean => isRecord(data) && isMatch(data, filter); diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index b8f8c516..a1b3397f 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -830,6 +830,10 @@ "heading": "Configuration upgrade available", "text": "A configuration upgrade is available. To upgrade, edit this card (Dashboard pencil icon \u2192 Three-dot menu \u2192 Edit) and click the 'Automatic Upgrade' button in the card editor" }, + "config_upgrade_failure": { + "heading": "Configuration could not be fully upgraded", + "text": "An automatic configuration upgrade did not complete successfully. Non-upgraded configuration has been set aside under '__UPGRADE_FAILURE__' in your configuration. Manually examine, upgrade and remove those entries." + }, "connection": { "lost": { "heading": "Connection lost", diff --git a/src/utils/action.ts b/src/utils/action.ts index b83fefd8..ad7fe7c6 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -37,6 +37,7 @@ import { ActionConfig, Actions, AdvancedCameraCardCustomActionConfig, + IfActionConfig, NotificationActionConfig, } from '../config/schema/actions/types.js'; import { AdvancedCameraCardUserSpecifiedView } from '../config/schema/common/const.js'; @@ -387,19 +388,50 @@ export function getActionConfigGivenAction( * @returns `true` if there's a real action defined, `false` otherwise. */ export const hasAction = (config?: ActionConfig | ActionConfig[]): boolean => { - return arrayify(config).some((item) => item.action !== 'none'); + return arrayify(config).some((item) => isIfAction(item) || item.action !== 'none'); +}; + +export const isIfAction = (action: ActionConfig): action is IfActionConfig => { + // The `if`/`then`/`else` action is structural: it has no `action:` + // discriminator and is identified by the presence of an `if` key. + return !('action' in action) && 'if' in action; +}; + +export const isStandardAction = ( + action: ActionConfig, +): action is Exclude => { + // Every action except the structural `if`/`then`/`else` action carries an + // `action:` discriminator. + return 'action' in action; }; export const isAdvancedCameraCardCustomAction = ( action: ActionConfig, ): action is AdvancedCameraCardCustomActionConfig => { return ( + isStandardAction(action) && action.action === 'fire-dom-event' && 'advanced_camera_card_action' in action && typeof action.advanced_camera_card_action === 'string' ); }; +/** + * Get a short human-readable name identifying an action (e.g. for confirmation + * prompts). + * @param action The action config. + * @returns The action's identifying name. + */ +export const getActionName = (action: ActionConfig): string => { + if (isIfAction(action)) { + return 'if'; + } + if (isAdvancedCameraCardCustomAction(action)) { + return action.advanced_camera_card_action; + } + return action.action; +}; + /** * Stop an event from activating card wide actions. */ diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 124b19be..290d6a84 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -233,6 +233,12 @@ export const setOrRemoveStyleProperty = ( */ export const isTruthy = (x: T | false | undefined | null | '' | 0): x is T => !!x; +/** + * Allow typescript to narrow an unknown value to a non-null object. + */ +export const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + /** * Allow typescript to narrow types for HTMLElements. */ @@ -291,7 +297,7 @@ export const desparsifyArrays = (data: T): T => { return ( data.filter((item) => item !== undefined).map((item) => desparsifyArrays(item)) ); - } else if (typeof data === 'object' && data !== null) { + } else if (isRecord(data)) { const result: Record = {}; for (const key in data) { result[key] = desparsifyArrays(data[key]); diff --git a/src/utils/keyed-subscription-manager.ts b/src/utils/concurrency/keyed-subscription-manager.ts similarity index 100% rename from src/utils/keyed-subscription-manager.ts rename to src/utils/concurrency/keyed-subscription-manager.ts diff --git a/src/utils/concurrency/serial-runner.ts b/src/utils/concurrency/serial-runner.ts new file mode 100644 index 00000000..9cd37ae2 --- /dev/null +++ b/src/utils/concurrency/serial-runner.ts @@ -0,0 +1,43 @@ +type Work = () => R; + +/** + * Runs work synchronously, but never re-entrantly. + * + * If `run` is called again from within an in-flight `run` (for example a + * listener that reacts to the work by triggering more work), the new work is + * queued and run after the current work completes, in the order it was + * enqueued -- never nested inside it. This keeps any state the work mutates + * coherent for the duration of each run, instead of being altered by a + * re-entrant change mid-run. + * + * The top-level call returns its work's result. A re-entrant (queued) call + * cannot return its result synchronously -- the work has not run yet -- so it + * returns `null`; only the top-level result is meaningful. + */ +export class SerialRunner { + private _queue: Work[] = []; + private _running = false; + + public run(work: Work): R | null { + // A re-entrant call is left for the in-flight run loop to drain. + if (this._running) { + this._queue.push(work); + return null; + } + + this._running = true; + try { + const result = work(); + while (this._queue.length) { + const batch = this._queue; + this._queue = []; + for (const queued of batch) { + queued(); + } + } + return result; + } finally { + this._running = false; + } + } +} diff --git a/src/view/unified-query-runner.ts b/src/view/unified-query-runner.ts index c04df52c..1fca8e5a 100644 --- a/src/view/unified-query-runner.ts +++ b/src/view/unified-query-runner.ts @@ -1,6 +1,6 @@ import { CameraManager } from '../camera-manager/manager'; import { FoldersManager } from '../card-controller/folders/manager'; -import { ConditionStateManagerReadonlyInterface } from '../conditions/types'; +import { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types'; import { ViewItem } from '../view/item'; import { UnifiedQuery } from '../view/unified-query'; diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 28cb0b44..f3adf07e 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -481,7 +481,9 @@ describe('CameraManager', () => { type: 'new', }; eventCallback?.(cameraEvent); - expect(api.getTriggersManager().handleCameraEvent).toBeCalledWith(cameraEvent); + expect(api.getCameraTriggersManager().handleCameraEvent).toBeCalledWith( + cameraEvent, + ); }); describe('should fetch entity list when required', () => { diff --git a/tests/card-controller/actions/actions-manager.test.ts b/tests/card-controller/actions/actions-manager.test.ts index c9c2e5cf..a44562a4 100644 --- a/tests/card-controller/actions/actions-manager.test.ts +++ b/tests/card-controller/actions/actions-manager.test.ts @@ -17,7 +17,10 @@ import { import type { CardController } from '../../../src/card-controller/controller'; import { TemplateRenderer } from '../../../src/card-controller/templates'; import { AdvancedCameraCardView } from '../../../src/config/schema/common/const'; -import { createGeneralAction, createLogAction } from '../../../src/utils/action'; +import { + createInternalCallbackAction, + createLogAction, +} from '../../../src/utils/action'; import { arrayify } from '../../../src/utils/basic'; import { createCardAPI, createConfig, createHASS, createView } from '../../test-utils'; @@ -347,7 +350,12 @@ describe('ActionsManager', () => { const manager = new ActionsManager(api, templateRenderer); const config = { entity: 'light.office' }; - const triggerData = { view: { from: 'previous-view', to: 'view' } }; + const triggerData = { + platform: 'acc', + type: 'view', + from_acc: { view: 'previous-view' }, + to_acc: { view: 'view' }, + }; vi.spyOn(global.console, 'info').mockReturnValue(undefined); await manager.executeActions({ actions: action, config, triggerData }); @@ -358,26 +366,233 @@ describe('ActionsManager', () => { }); }); - it('should filter rendered actions through the lock manager', async () => { - const renderedAction = createGeneralAction('reload'); - const allowedAction = createLogAction('Allowed'); + it('should filter actions through the lock manager before rendering them', async () => { + const rawRan = vi.fn(); + const allowedRan = vi.fn(); + const rawAction = createInternalCallbackAction(async () => { + rawRan(); + }); + const allowedAction = createInternalCallbackAction(async () => { + allowedRan(); + }); const templateRenderer = mock(); - templateRenderer.renderRecursively.mockReturnValue(renderedAction); + templateRenderer.renderRecursively.mockReturnValue(allowedAction); const api = createAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getLockManager().getAllowedActions).mockReturnValue([allowedAction]); const manager = new ActionsManager(api, templateRenderer); + + await manager.executeActions({ actions: rawAction }); + + // The lock manager sees the raw (unrendered) action; only the action it + // returns is rendered and run. + expect(api.getLockManager().getAllowedActions).toBeCalledWith(rawAction); + expect(allowedRan).toBeCalled(); + expect(rawRan).not.toBeCalled(); + }); + + it('should render each action against the state at its turn', async () => { + let camera = 'first'; + + const templateRenderer = mock(); + // Identity render -- assert on the render *inputs*, not a swapped output. + templateRenderer.renderRecursively.mockImplementation((_hass, data) => data); + + const api = createAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConditionStateManager().getState).mockImplementation(() => ({ + camera, + })); + + const manager = new ActionsManager(api, templateRenderer); + + await manager.executeActions({ + actions: [ + // The first action changes the camera... + createInternalCallbackAction(async () => { + camera = 'second'; + }), + // ...the second is rendered afterwards. + { action: 'none' }, + ], + }); + + // Each action renders with the state as it is at its turn: the second + // sees the camera the first action set. + expect(templateRenderer.renderRecursively).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + expect.objectContaining({ conditionState: { camera: 'first' } }), + ); + expect(templateRenderer.renderRecursively).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.anything(), + expect.objectContaining({ conditionState: { camera: 'second' } }), + ); + }); + + it('should render against the hass available at each step', async () => { + const ran: string[] = []; + + const templateRenderer = mock(); + templateRenderer.renderRecursively.mockImplementation((_hass, data) => data); + + const api = createAPI(); + // No HASS for the first action's render; HASS thereafter. + vi.mocked(api.getHASSManager().getHASS) + .mockReturnValueOnce(null) + .mockReturnValue(createHASS()); + + const manager = new ActionsManager(api, templateRenderer); + + await manager.executeActions({ + actions: [ + createInternalCallbackAction(async () => { + ran.push('one'); + }), + createInternalCallbackAction(async () => { + ran.push('two'); + }), + ], + }); + + // Both actions ran; only the second was rendered -- the first saw no + // HASS, so HASS is read per action rather than captured once. + expect(ran).toEqual(['one', 'two']); + expect(templateRenderer.renderRecursively).toBeCalledTimes(1); + }); + + it('should abort the remaining actions when one fails to render', async () => { + const ran: string[] = []; + + const templateRenderer = mock(); + templateRenderer.renderRecursively + .mockImplementationOnce((_hass, data) => data) + .mockImplementationOnce(() => { + throw new Error('bad template'); + }); + + const api = createAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + + const manager = new ActionsManager(api, templateRenderer); + const warnSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + + await manager.executeActions({ + actions: [ + createInternalCallbackAction(async () => { + ran.push('first'); + }), + createInternalCallbackAction(async () => { + ran.push('second'); + }), + createInternalCallbackAction(async () => { + ran.push('third'); + }), + ], + }); + + // The first action ran; the second's render threw, aborting the rest. The + // error was caught by executeActions(). + expect(ran).toEqual(['first']); + expect(warnSpy).toBeCalled(); + }); + + it('should hand an if-action branch to the executor unrendered, with the trigger data', async () => { + const api = createAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({ + fullscreen: true, + }); + + const manager = new ActionsManager(api, new TemplateRenderer()); + const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); + + const thenAction = createLogAction('{{ trigger.entity_id }}'); + await manager.executeActions({ + actions: { + if: [{ condition: 'fullscreen', fullscreen: true }], + then: [thenAction], + else: [createLogAction('unused else')], + }, + triggerData: { platform: 'state', entity_id: 'binary_sensor.door' }, + }); + + // The branch is left raw (template intact) and forwarded with the trigger + // data, so the nested executor renders it per-step when it runs -- not + // frozen against the state at the `if` step. + expect(api.getActionsManager().executeActions).toBeCalledWith({ + actions: [thenAction], + config: undefined, + triggerData: { platform: 'state', entity_id: 'binary_sensor.door' }, + }); + + // The log action is handed to the (mocked) nested executor, not run here, + // so it must not actually log. + expect(consoleSpy).not.toBeCalled(); + }); + + it('should render if-action branch actions per-step', async () => { + let camera = 'before'; + + const api = createAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConditionStateManager().getState).mockImplementation(() => ({ + camera, + fullscreen: true, + })); + + const manager = new ActionsManager(api, new TemplateRenderer()); + // The if-action's nested executor is the same (real) manager. + vi.mocked(api.getActionsManager).mockReturnValue(manager); + const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); await manager.executeActions({ - actions: createLogAction('{{ action }}'), + actions: { + if: [{ condition: 'fullscreen', fullscreen: true }], + then: [ + // The first branch action changes the camera... + createInternalCallbackAction(async () => { + camera = 'after'; + }), + // ...the second logs `{{ acc.camera }}`, rendered at its own turn. + createLogAction('{{ acc.camera }}'), + ], + }, }); - expect(api.getLockManager().getAllowedActions).toBeCalledWith(renderedAction); - expect(consoleSpy).toBeCalledWith('Allowed'); + // The log rendered against the camera the first branch action set, so it + // logs 'after' -- proving the branch renders per-step. Frozen-at-the-`if` + // rendering would log 'before'. + expect(consoleSpy).toHaveBeenCalledWith('after'); + }); + + it('should drop an action whose templated discriminator cannot be classified', async () => { + const api = createAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + + const manager = new ActionsManager(api, new TemplateRenderer()); + const warnSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + + await manager.executeActions({ + actions: { + action: 'fire-dom-event', + advanced_camera_card_action: '{{ acc.view }}', + }, + }); + + // The discriminator is classified on the raw action (templates render + // only afterwards), so a templated `advanced_camera_card_action` matches + // no action type and is dropped with a warning rather than executed. + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('unknown card action'), + ); }); it('should not execute actions when the lock manager rejects them', async () => { diff --git a/tests/card-controller/actions/actions/camera-select.test.ts b/tests/card-controller/actions/actions/camera-select.test.ts index 717608db..81c0dea1 100644 --- a/tests/card-controller/actions/actions/camera-select.test.ts +++ b/tests/card-controller/actions/actions/camera-select.test.ts @@ -123,9 +123,9 @@ describe('should handle camera_select action', () => { it('with triggered camera', async () => { const api = createCardAPI(); vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); - vi.mocked(api.getTriggersManager().getMostRecentlyTriggeredCameraID).mockReturnValue( - 'camera.office', - ); + vi.mocked( + api.getCameraTriggersManager().getMostRecentlyTriggeredCameraID, + ).mockReturnValue('camera.office'); const action = new CameraSelectAction( {}, @@ -152,9 +152,9 @@ describe('should handle camera_select action', () => { it('without camera or triggered camera', async () => { const api = createCardAPI(); vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); - vi.mocked(api.getTriggersManager().getMostRecentlyTriggeredCameraID).mockReturnValue( - 'camera.office', - ); + vi.mocked( + api.getCameraTriggersManager().getMostRecentlyTriggeredCameraID, + ).mockReturnValue('camera.office'); const action = new CameraSelectAction( {}, diff --git a/tests/card-controller/actions/actions/if.test.ts b/tests/card-controller/actions/actions/if.test.ts new file mode 100644 index 00000000..925a9083 --- /dev/null +++ b/tests/card-controller/actions/actions/if.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; +import { IfAction } from '../../../../src/card-controller/actions/actions/if'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { createCardAPI } from '../../../test-utils'; + +const thenActions = [ + { + action: 'fire-dom-event' as const, + advanced_camera_card_action: 'clips' as const, + }, +]; +const elseActions = [ + { + action: 'fire-dom-event' as const, + advanced_camera_card_action: 'clip' as const, + }, +]; + +describe('IfAction', () => { + it('should run the then branch when the conditions hold', async () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + stateManager.setState({ fullscreen: true }); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const action = new IfAction( + {}, + { + if: [{ condition: 'fullscreen', fullscreen: true }], + then: thenActions, + else: elseActions, + }, + ); + + await action.execute(api); + + expect(api.getActionsManager().executeActions).toBeCalledWith({ + actions: thenActions, + config: undefined, + triggerData: undefined, + }); + }); + + it('should run the else branch when the conditions do not hold', async () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + stateManager.setState({ fullscreen: false }); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const action = new IfAction( + {}, + { + if: [{ condition: 'fullscreen', fullscreen: true }], + then: thenActions, + else: elseActions, + }, + ); + + await action.execute(api); + + expect(api.getActionsManager().executeActions).toBeCalledWith({ + actions: elseActions, + config: undefined, + triggerData: undefined, + }); + }); + + it('should do nothing when the conditions do not hold and there is no else branch', async () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + stateManager.setState({ fullscreen: false }); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const action = new IfAction( + {}, + { + if: [{ condition: 'fullscreen', fullscreen: true }], + then: thenActions, + }, + ); + + await action.execute(api); + + expect(api.getActionsManager().executeActions).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/actions/factory.test.ts b/tests/card-controller/actions/factory.test.ts index cd2743b6..10d22faa 100644 --- a/tests/card-controller/actions/factory.test.ts +++ b/tests/card-controller/actions/factory.test.ts @@ -12,6 +12,7 @@ import { DownloadAction } from '../../../src/card-controller/actions/actions/dow import { EffectAction } from '../../../src/card-controller/actions/actions/effect'; import { ExpandAction } from '../../../src/card-controller/actions/actions/expand'; import { FullscreenAction } from '../../../src/card-controller/actions/actions/fullscreen'; +import { IfAction } from '../../../src/card-controller/actions/actions/if'; import { InfoAction } from '../../../src/card-controller/actions/actions/info'; import { InternalCallbackAction } from '../../../src/card-controller/actions/actions/internal-callback'; import { LogAction } from '../../../src/card-controller/actions/actions/log'; @@ -87,6 +88,11 @@ describe('ActionFactory', () => { }); }); + it('should create an if action', () => { + const factory = new ActionFactory(); + expect(factory.createAction({}, { if: [], then: [] })).toBeInstanceOf(IfAction); + }); + describe('custom actions', () => { it.each([ [{ advanced_camera_card_action: 'call_answer' as const }, CallAnswerAction], diff --git a/tests/card-controller/automations-manager.test.ts b/tests/card-controller/automations-manager.test.ts index aa795035..6e950c83 100644 --- a/tests/card-controller/automations-manager.test.ts +++ b/tests/card-controller/automations-manager.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ActionsExecutionRequest } from '../../src/card-controller/actions/types.js'; import { AutomationsManager } from '../../src/card-controller/automations-manager.js'; -import { ConditionStateManager } from '../../src/conditions/state-manager.js'; -import { createCardAPI } from '../test-utils.js'; +import { ConditionStateManager } from '../../src/condition-trigger/conditions/state-manager.js'; +import { createCardAPI, flushPromises } from '../test-utils.js'; describe('AutomationsManager', () => { const actions = [ @@ -11,15 +11,11 @@ describe('AutomationsManager', () => { advanced_camera_card_action: 'clips', }, ]; - const conditions = [{ condition: 'fullscreen' as const, fullscreen: true }]; + const triggers = [{ trigger: 'fullscreen' as const, fullscreen: true }]; const automation = { - conditions: conditions, + triggers: triggers, actions: actions, }; - const not_automation = { - conditions: conditions, - actions_not: actions, - }; afterEach(() => { vi.clearAllMocks(); @@ -78,7 +74,7 @@ describe('AutomationsManager', () => { }); }); - it('should execute actions', () => { + it('should execute actions when triggered', () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true); vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( @@ -94,8 +90,7 @@ describe('AutomationsManager', () => { expect(api.getActionsManager().executeActions).toBeCalledTimes(1); - // Automation will not re-fire when condition continues to evaluate the - // same. + // It does not re-trigger while its source stays in the same state. stateManager.setState({ fullscreen: true }); expect(api.getActionsManager().executeActions).toBeCalledTimes(1); @@ -106,7 +101,7 @@ describe('AutomationsManager', () => { expect(api.getActionsManager().executeActions).toBeCalledTimes(2); }); - it('should execute actions_not', () => { + it('should run actions when the ongoing conditions hold', () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true); vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( @@ -116,13 +111,69 @@ describe('AutomationsManager', () => { vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); const automationsManager = new AutomationsManager(api); - automationsManager.addAutomations([not_automation]); + automationsManager.addAutomations([ + { + triggers: triggers, + conditions: [{ condition: 'expand' as const, expand: true }], + actions: actions, + }, + ]); + + // The ongoing condition holds when triggered, so `actions` run. + stateManager.setState({ expand: true }); + stateManager.setState({ fullscreen: true }); + + expect(api.getActionsManager().executeActions).toBeCalledWith({ + actions: actions, + triggerData: { platform: 'acc', type: 'fullscreen' }, + }); + }); + + it('should do nothing when the ongoing conditions do not hold', () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true); + vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( + true, + ); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const automationsManager = new AutomationsManager(api); + automationsManager.addAutomations([ + { + triggers: triggers, + conditions: [{ condition: 'expand' as const, expand: true }], + actions: actions, + }, + ]); + + // The automation is triggered but the ongoing condition does not hold, so + // nothing runs. + stateManager.setState({ fullscreen: true }); + + expect(api.getActionsManager().executeActions).not.toBeCalled(); + }); + + it('should do nothing when the actions are empty', () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true); + vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( + true, + ); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const automationsManager = new AutomationsManager(api); + automationsManager.addAutomations([ + { + triggers: triggers, + actions: [], + }, + ]); stateManager.setState({ fullscreen: true }); - expect(api.getActionsManager().executeActions).not.toBeCalled(); - stateManager.setState({ fullscreen: false }); - expect(api.getActionsManager().executeActions).toBeCalled(); + expect(api.getActionsManager().executeActions).not.toBeCalled(); }); it('should prevent automation loops', () => { @@ -137,29 +188,27 @@ describe('AutomationsManager', () => { const automationsManager = new AutomationsManager(api); automationsManager.addAutomations([ { - conditions: [{ condition: 'fullscreen' as const, fullscreen: true }], + triggers: [{ trigger: 'camera' as const }], actions: actions, }, - { - conditions: [{ condition: 'fullscreen' as const, fullscreen: false }], - actions_not: actions, - }, ]); - // Create a setup where one automation action causes another... - let fullscreen = true; + // Create a setup where the automation's action re-triggers itself: the camera + // trigger responds to every camera change, and the action changes the camera, + // looping until the nested-execution guard trips. + let camera = 'one'; vi.mocked(api.getActionsManager().executeActions).mockImplementation( async ( // eslint-disable-next-line @typescript-eslint/no-unused-vars _request: ActionsExecutionRequest, ): Promise => { - fullscreen = !fullscreen; - stateManager.setState({ fullscreen: fullscreen }); + camera = camera === 'one' ? 'two' : 'one'; + stateManager.setState({ camera: camera }); }, ); - stateManager.setState({ fullscreen: fullscreen }); + stateManager.setState({ camera: camera }); expect(api.getNotificationManager().setNotification).toBeCalledWith({ heading: { @@ -172,6 +221,49 @@ describe('AutomationsManager', () => { expect(api.getActionsManager().executeActions).toBeCalledTimes(10); }); + it('should reset the nested-execution counter after an overflow', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true); + vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( + true, + ); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const automationsManager = new AutomationsManager(api); + automationsManager.addAutomations([ + { + triggers: [{ trigger: 'camera' as const }], + actions: actions, + }, + ]); + + // As in the loop test: the action changes the camera, re-triggering itself + // until the nested-execution guard trips. + let camera = 'one'; + vi.mocked(api.getActionsManager().executeActions).mockImplementation( + async (): Promise => { + camera = camera === 'one' ? 'two' : 'one'; + stateManager.setState({ camera: camera }); + }, + ); + + stateManager.setState({ camera: camera }); + expect(api.getActionsManager().executeActions).toBeCalledTimes(10); + + // The counter is decremented on the microtasks that resume after each + // awaited execution, so let them drain before the next batch. + await flushPromises(); + + vi.mocked(api.getActionsManager().executeActions).mockClear(); + + // A second, independent change overflows afresh and reaches the full limit + // again -- only possible if the counter returned to zero. A leaked counter + // (overflow returning without decrementing) would cut this batch short. + stateManager.setState({ camera: 'three' }); + expect(api.getActionsManager().executeActions).toBeCalledTimes(10); + }); + it('should delete automations', () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true); @@ -184,11 +276,11 @@ describe('AutomationsManager', () => { const automationsManager = new AutomationsManager(api); automationsManager.addAutomations([ { - conditions: [{ condition: 'expand' as const, expand: true }], + triggers: [{ trigger: 'expand' as const, expand: true }], actions: actions, }, { - conditions: [{ condition: 'fullscreen' as const, fullscreen: true }], + triggers: [{ trigger: 'fullscreen' as const, fullscreen: true }], actions: actions, tag: 'fullscreen', }, @@ -210,8 +302,8 @@ describe('AutomationsManager', () => { // Delete all automations. automationsManager.deleteAutomations(); - stateManager.setState({ fullscreen: false }); - stateManager.setState({ fullscreen: true }); + stateManager.setState({ expand: false }); + stateManager.setState({ expand: true }); expect(api.getActionsManager().executeActions).toBeCalledTimes(2); }); }); diff --git a/tests/card-controller/call/manager.test.ts b/tests/card-controller/call/manager.test.ts index 45f22047..933cc035 100644 --- a/tests/card-controller/call/manager.test.ts +++ b/tests/card-controller/call/manager.test.ts @@ -8,7 +8,7 @@ import { CallManager } from '../../../src/card-controller/call/manager'; import { Ringtone } from '../../../src/card-controller/call/ringtone'; import { CardController } from '../../../src/card-controller/controller'; import { SubstreamViewModifier } from '../../../src/card-controller/view/modifiers/substream'; -import { ConditionStateChange } from '../../../src/conditions/types'; +import { ConditionStateChange } from '../../../src/condition-trigger/conditions/types'; import { RingtoneConfig } from '../../../src/config/schema/live'; import { AdvancedCameraCardConfig } from '../../../src/config/schema/types'; import { View } from '../../../src/view/view'; diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/camera-triggers-manager.test.ts similarity index 94% rename from tests/card-controller/triggers-manager.test.ts rename to tests/card-controller/camera-triggers-manager.test.ts index b1e9d80d..c79b9bf9 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/camera-triggers-manager.test.ts @@ -2,7 +2,7 @@ import { add } from 'date-fns'; import { PartialDeep } from 'type-fest'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CardController } from '../../src/card-controller/controller'; -import { TriggersManager } from '../../src/card-controller/triggers-manager'; +import { CameraTriggersManager } from '../../src/card-controller/camera-triggers-manager'; import { AdvancedCameraCardView } from '../../src/config/schema/common/const'; import { TriggersOptions, triggersSchema } from '../../src/config/schema/view'; import { @@ -85,7 +85,7 @@ const createTriggerAPI = (options?: { }; // @vitest-environment jsdom -describe('TriggersManager', () => { +describe('CameraTriggersManager', () => { const start = new Date('2023-10-01T17:14'); beforeEach(() => { @@ -94,7 +94,7 @@ describe('TriggersManager', () => { }); it('should not be triggered by default', () => { - const manager = new TriggersManager(createCardAPI()); + const manager = new CameraTriggersManager(createCardAPI()); expect(manager.isTriggered()).toBeFalsy(); }); @@ -106,7 +106,7 @@ describe('TriggersManager', () => { }, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -122,7 +122,7 @@ describe('TriggersManager', () => { const api = createTriggerAPI(); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -145,7 +145,7 @@ describe('TriggersManager', () => { }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -170,7 +170,7 @@ describe('TriggersManager', () => { }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -197,7 +197,7 @@ describe('TriggersManager', () => { }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -240,7 +240,7 @@ describe('TriggersManager', () => { }, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -280,7 +280,7 @@ describe('TriggersManager', () => { }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -300,7 +300,7 @@ describe('TriggersManager', () => { }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -327,7 +327,7 @@ describe('TriggersManager', () => { }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -356,7 +356,7 @@ describe('TriggersManager', () => { }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', @@ -390,7 +390,7 @@ describe('TriggersManager', () => { }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', @@ -418,7 +418,7 @@ describe('TriggersManager', () => { }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', @@ -445,7 +445,7 @@ describe('TriggersManager', () => { it('should handle untrigger call with no state', async () => { const api = createTriggerAPI(); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -458,7 +458,7 @@ describe('TriggersManager', () => { it('should not untrigger if other sources are still active', async () => { const api = createTriggerAPI(); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -501,7 +501,7 @@ describe('TriggersManager', () => { it('should cancel untrigger timer if a new trigger starts', async () => { const api = createTriggerAPI(); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -551,7 +551,7 @@ describe('TriggersManager', () => { ]), ); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -604,7 +604,7 @@ describe('TriggersManager', () => { untrigger_delay_seconds: 10, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); // 1. Trigger the camera. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); @@ -630,7 +630,7 @@ describe('TriggersManager', () => { untrigger_delay_seconds: 10, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); // Trigger then end to start the untrigger delay timer. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); @@ -656,7 +656,7 @@ describe('TriggersManager', () => { untrigger_force_seconds: 10, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); // Trigger to start the force untrigger timer. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); @@ -680,7 +680,7 @@ describe('TriggersManager', () => { untrigger_delay_seconds: 0, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', @@ -704,7 +704,7 @@ describe('TriggersManager', () => { untrigger_force_seconds: 5, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -726,7 +726,7 @@ describe('TriggersManager', () => { untrigger_force_seconds: 10, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -758,7 +758,7 @@ describe('TriggersManager', () => { untrigger_force_seconds: 5, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -785,7 +785,7 @@ describe('TriggersManager', () => { untrigger_force_seconds: 10, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -816,7 +816,7 @@ describe('TriggersManager', () => { actions: { trigger: 'none', untrigger: 'default' }, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -842,7 +842,7 @@ describe('TriggersManager', () => { actions: { trigger: 'none', untrigger: 'default' }, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -861,7 +861,7 @@ describe('TriggersManager', () => { actions: { trigger: 'none', untrigger: 'default' }, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); // Continuous source comes on first. await manager.handleCameraEvent({ @@ -894,7 +894,7 @@ describe('TriggersManager', () => { actions: { trigger: 'none', untrigger: 'default' }, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -924,7 +924,7 @@ describe('TriggersManager', () => { actions: { trigger: 'none', untrigger: 'default' }, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -963,7 +963,7 @@ describe('TriggersManager', () => { }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -1007,7 +1007,7 @@ describe('TriggersManager', () => { default: 'live', }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -1032,7 +1032,7 @@ describe('TriggersManager', () => { default: 'clips', }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -1051,7 +1051,7 @@ describe('TriggersManager', () => { // Interaction present. interaction: true, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', @@ -1089,7 +1089,7 @@ describe('TriggersManager', () => { }, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', @@ -1126,7 +1126,7 @@ describe('TriggersManager', () => { }, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', @@ -1179,7 +1179,7 @@ describe('TriggersManager', () => { ]), ); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); expect(manager.isTriggered()).toBeFalsy(); expect(manager.getMostRecentlyTriggeredCameraID()).toBeNull(); @@ -1228,7 +1228,7 @@ describe('TriggersManager', () => { filter_selected_camera: true, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); expect(manager.isTriggered()).toBeFalsy(); const otherCameraSelected = createView({ @@ -1288,7 +1288,7 @@ describe('TriggersManager', () => { ]), ); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); const primaryCameraView = createView({ camera: 'camera_primary' as const, @@ -1315,7 +1315,7 @@ describe('TriggersManager', () => { actions: { trigger: 'none', untrigger: 'default' }, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); // Trigger camera_1 normally, then end it -- starts the delay timer. await manager.handleCameraEvent({ @@ -1372,7 +1372,7 @@ describe('TriggersManager', () => { ]), ); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); const result = await manager.handleInitialCameraTriggers(); expect(result).toBeFalsy(); @@ -1404,7 +1404,7 @@ describe('TriggersManager', () => { }); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); const result = await manager.handleInitialCameraTriggers(); expect(result).toBeFalsy(); @@ -1436,7 +1436,7 @@ describe('TriggersManager', () => { }); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); const result = await manager.handleInitialCameraTriggers(); expect(result).toBeTruthy(); @@ -1468,7 +1468,7 @@ describe('TriggersManager', () => { }); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); const result = await manager.handleInitialCameraTriggers(); expect(result).toBeTruthy(); @@ -1515,7 +1515,7 @@ describe('TriggersManager', () => { }); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); const result = await manager.handleInitialCameraTriggers(); expect(result).toBeTruthy(); @@ -1555,7 +1555,7 @@ describe('TriggersManager', () => { }); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); const result = await manager.handleInitialCameraTriggers(); // A trigger entity was active... @@ -1580,7 +1580,7 @@ describe('TriggersManager', () => { }, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'event-1', @@ -1620,7 +1620,7 @@ describe('TriggersManager', () => { }, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'end' }); @@ -1642,7 +1642,7 @@ describe('TriggersManager', () => { untrigger_delay_seconds: 0, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); // Trigger then end, but don't await the end event so we can check // triggered IDs while the camera is in the map but no longer triggered. @@ -1660,7 +1660,7 @@ describe('TriggersManager', () => { it('should handle newly missing configuration', async () => { const api = createTriggerAPI(); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); // 1. Trigger the camera with valid config. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); @@ -1688,7 +1688,7 @@ describe('TriggersManager', () => { untrigger_force_seconds: 10, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); // 1. Initial trigger. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); @@ -1717,7 +1717,7 @@ describe('TriggersManager', () => { untrigger_force_seconds: 10, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); // 1. Trigger and force untrigger. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); @@ -1747,7 +1747,7 @@ describe('TriggersManager', () => { }, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); // Simulate an update for a brand new ID (e.g. from an engine we just switched to). // Even without a 'new' event, if it's not in our ignore list, it should be processed. @@ -1768,7 +1768,7 @@ describe('TriggersManager', () => { untrigger_force_seconds: 10, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); // Force-ignore e1 on camera_1. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); @@ -1794,7 +1794,7 @@ describe('TriggersManager', () => { untrigger_force_seconds: 10, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); // Force-ignore e1 and e2 on camera_1. await manager.handleCameraEvent({ cameraID: 'camera_1', id: 'e1', type: 'new' }); @@ -1825,7 +1825,7 @@ describe('TriggersManager', () => { untrigger_delay_seconds: 0, }, }); - const manager = new TriggersManager(api); + const manager = new CameraTriggersManager(api); // Call handleCameraEvent with an unknown camera ID to ensure guard behavior. const result = await manager.handleCameraEvent({ diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index 7b425f8a..c0c4c035 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -5,9 +5,9 @@ import { ConfigManager } from '../../../src/card-controller/config/config-manage import { setRemoteControlEntityFromConfig } from '../../../src/card-controller/config/load-control-entities'; import { setKeyboardShortcutsFromConfig } from '../../../src/card-controller/config/load-keyboard-shortcuts'; import { InitializationAspect } from '../../../src/card-controller/initialization-manager'; -import { ConditionStateManager } from '../../../src/conditions/state-manager'; +import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager'; import { Automation } from '../../../src/config/schema/automations'; -import { AdvancedCameraCardCondition } from '../../../src/config/schema/conditions/types'; +import { Trigger } from '../../../src/config/schema/condition-trigger/triggers/types'; import { advancedCameraCardConfigSchema } from '../../../src/config/schema/types'; import { createGeneralAction } from '../../../src/utils/action'; import { createCardAPI, createConfig, flushPromises } from '../../test-utils'; @@ -507,8 +507,8 @@ describe('ConfigManager', () => { expect(addAutomationsSpy).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ - conditions: expect.arrayContaining([ - expect.objectContaining({ condition: 'key', key: 'h' }), + triggers: expect.arrayContaining([ + expect.objectContaining({ trigger: 'key', key: 'h' }), ]), actions: expect.arrayContaining([ expect.objectContaining({ @@ -530,9 +530,8 @@ describe('ConfigManager', () => { const addCalls = addAutomationsSpy.mock.calls; const hasKeyboardShortcut = addCalls.some((call) => call[0].some((automation: Automation) => - automation.conditions?.some( - (cond: AdvancedCameraCardCondition) => - cond.condition === 'key' && cond.key === 'h', + automation.triggers.some( + (trig: Trigger) => trig.trigger === 'key' && trig.key === 'h', ), ), ); @@ -610,7 +609,7 @@ describe('ConfigManager', () => { ); const automation = { - conditions: [TEST_CONDITIONS.FULLSCREEN_OFF], + triggers: [{ trigger: 'fullscreen' as const, fullscreen: false }], actions: [createGeneralAction('screenshot')], }; const config = createConfig({ @@ -672,7 +671,7 @@ describe('ConfigManager', () => { ); const automation = { - conditions: [TEST_CONDITIONS.FULLSCREEN_ON], + triggers: [{ trigger: 'fullscreen' as const, fullscreen: true }], actions: [createGeneralAction('screenshot')], }; const config = createConfig({ @@ -728,9 +727,9 @@ describe('ConfigManager', () => { expect(addAutomationsSpy).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ - conditions: expect.arrayContaining([ + triggers: expect.arrayContaining([ expect.objectContaining({ - condition: 'config', + trigger: 'config', paths: expect.arrayContaining(['remote_control.entities.camera']), }), ]), @@ -752,10 +751,10 @@ describe('ConfigManager', () => { const addCalls = addAutomationsSpy.mock.calls; const hasRemoteControl = addCalls.some((call) => call[0].some((automation: Automation) => - automation.conditions?.some( - (cond: AdvancedCameraCardCondition) => - cond.condition === 'config' && - cond.paths?.includes('remote_control.entities.camera'), + automation.triggers.some( + (trig: Trigger) => + trig.trigger === 'config' && + trig.paths?.includes('remote_control.entities.camera'), ), ), ); @@ -771,9 +770,9 @@ describe('ConfigManager', () => { expect(addAutomationsSpy).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ - conditions: expect.arrayContaining([ + triggers: expect.arrayContaining([ expect.objectContaining({ - condition: 'config', + trigger: 'config', paths: expect.arrayContaining(['remote_control.entities.camera']), }), ]), diff --git a/tests/card-controller/config/load-automations.test.ts b/tests/card-controller/config/load-automations.test.ts index 68f5f0b0..df92da9f 100644 --- a/tests/card-controller/config/load-automations.test.ts +++ b/tests/card-controller/config/load-automations.test.ts @@ -20,7 +20,7 @@ describe('setAutomationsFromConfig', () => { advanced_camera_card_action: 'clips', }, ], - conditions: [{ condition: 'fullscreen' as const, fullscreen: true }], + triggers: [{ trigger: 'fullscreen' as const, fullscreen: true }], }, ]; const api = createCardAPI(); diff --git a/tests/card-controller/config/load-control-entities.test.ts b/tests/card-controller/config/load-control-entities.test.ts index ee10a09e..c17fc950 100644 --- a/tests/card-controller/config/load-control-entities.test.ts +++ b/tests/card-controller/config/load-control-entities.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it, vi } from 'vitest'; +import { assert, describe, expect, it, vi } from 'vitest'; +import { AutomationsManager } from '../../../src/card-controller/automations-manager'; import { setRemoteControlEntityFromConfig } from '../../../src/card-controller/config/load-control-entities'; +import { TemplateRenderer } from '../../../src/card-controller/templates/index'; +import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager'; import { INTERNAL_CALLBACK_ACTION, InternalCallbackActionConfig, @@ -48,9 +51,9 @@ describe('setRemoteControlEntityFromConfig', () => { callback: expect.any(Function), }, ], - conditions: [ + triggers: [ { - condition: 'config', + trigger: 'config', paths: ['cameras', 'remote_control.entities.camera'], }, ], @@ -64,9 +67,9 @@ describe('setRemoteControlEntityFromConfig', () => { callback: expect.any(Function), }, ], - conditions: [ + triggers: [ { - condition: 'camera', + trigger: 'camera', }, ], tag: setRemoteControlEntityFromConfig, @@ -79,9 +82,9 @@ describe('setRemoteControlEntityFromConfig', () => { callback: expect.any(Function), }, ], - conditions: [ + triggers: [ { - condition: 'initialized', + trigger: 'initialized', }, ], tag: setRemoteControlEntityFromConfig, @@ -91,13 +94,13 @@ describe('setRemoteControlEntityFromConfig', () => { { action: 'fire-dom-event', advanced_camera_card_action: 'camera_select', - camera: '{{ advanced_camera_card.trigger.state.to }}', + camera: '{{ trigger.to_state.state }}', }, ], - conditions: [ + triggers: [ { - condition: 'state', - entity: 'input_select.camera', + trigger: 'state', + entity_id: 'input_select.camera', }, ], tag: setRemoteControlEntityFromConfig, @@ -130,9 +133,9 @@ describe('setRemoteControlEntityFromConfig', () => { callback: expect.any(Function), }, ], - conditions: [ + triggers: [ { - condition: 'config', + trigger: 'config', paths: ['cameras', 'remote_control.entities.camera'], }, ], @@ -146,9 +149,9 @@ describe('setRemoteControlEntityFromConfig', () => { callback: expect.any(Function), }, ], - conditions: [ + triggers: [ { - condition: 'camera', + trigger: 'camera', }, ], tag: setRemoteControlEntityFromConfig, @@ -161,9 +164,9 @@ describe('setRemoteControlEntityFromConfig', () => { camera: '{{ hass.states["input_select.camera"].state }}', }, ], - conditions: [ + triggers: [ { - condition: 'initialized', + trigger: 'initialized', }, ], tag: setRemoteControlEntityFromConfig, @@ -173,13 +176,13 @@ describe('setRemoteControlEntityFromConfig', () => { { action: 'fire-dom-event', advanced_camera_card_action: 'camera_select', - camera: '{{ advanced_camera_card.trigger.state.to }}', + camera: '{{ trigger.to_state.state }}', }, ], - conditions: [ + triggers: [ { - condition: 'state', - entity: 'input_select.camera', + trigger: 'state', + entity_id: 'input_select.camera', }, ], tag: setRemoteControlEntityFromConfig, @@ -618,4 +621,64 @@ describe('setRemoteControlEntityFromConfig', () => { ); }); }); + + describe('should render the firing trigger into the camera action', () => { + it('should resolve the new entity state through the trigger template', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + remote_control: { + entities: { + camera: 'input_select.camera', + camera_priority: 'card', + }, + }, + }), + ); + + // Capture the automations the loader registers, then drive them through a + // real AutomationsManager so the state trigger actually fires and its + // `{{ trigger.to_state.state }}` template is rendered end to end (the + // other tests only string-match the literal, so a context mis-wire would + // silently break remote control). + setRemoteControlEntityFromConfig(api); + const automations = vi.mocked(api.getAutomationsManager().addAutomations).mock + .calls[0][0]; + + vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true); + vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( + true, + ); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + new AutomationsManager(api).addAutomations(automations); + + stateManager.setState({ + hass: createHASS({ + 'input_select.camera': createStateEntity({ state: 'camera.one' }), + }), + }); + const hass = createHASS({ + 'input_select.camera': createStateEntity({ state: 'camera.two' }), + }); + stateManager.setState({ hass }); + + const captured = vi + .mocked(api.getActionsManager().executeActions) + .mock.calls.at(-1)?.[0]; + assert(captured); + + const rendered = new TemplateRenderer().renderRecursively(hass, captured.actions, { + triggerData: captured.triggerData, + }); + expect(rendered).toEqual([ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'camera_select', + camera: 'camera.two', + }, + ]); + }); + }); }); diff --git a/tests/card-controller/config/load-keyboard-shortcuts.test.ts b/tests/card-controller/config/load-keyboard-shortcuts.test.ts index e0ceb9ea..238899a7 100644 --- a/tests/card-controller/config/load-keyboard-shortcuts.test.ts +++ b/tests/card-controller/config/load-keyboard-shortcuts.test.ts @@ -78,10 +78,10 @@ describe('setKeyboardShortcutsFromConfig', () => { ptz_phase: 'start', }, ], - conditions: [ + triggers: [ { alt: undefined, - condition: 'key', + trigger: 'key', ctrl: undefined, key: 'z', meta: undefined, @@ -100,9 +100,9 @@ describe('setKeyboardShortcutsFromConfig', () => { ptz_phase: 'stop', }, ], - conditions: [ + triggers: [ { - condition: 'key', + trigger: 'key', key: 'z', state: 'up', }, @@ -130,10 +130,10 @@ describe('setKeyboardShortcutsFromConfig', () => { advanced_camera_card_action: 'ptz_multi', }, ], - conditions: [ + triggers: [ { alt: undefined, - condition: 'key', + trigger: 'key', ctrl: undefined, key: 'h', meta: undefined, diff --git a/tests/card-controller/config/overrides-manager.test.ts b/tests/card-controller/config/overrides-manager.test.ts index d12bd47f..178e33c7 100644 --- a/tests/card-controller/config/overrides-manager.test.ts +++ b/tests/card-controller/config/overrides-manager.test.ts @@ -1,7 +1,7 @@ import { assert, describe, expect, it, vi } from 'vitest'; import { OverridesManager } from '../../../src/card-controller/config/overrides-manager'; +import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager'; import { AdvancedCameraCardConfig } from '../../../src/config/schema/types'; -import { ConditionStateManager } from '../../../src/conditions/state-manager'; import { AdvancedCameraCardError } from '../../../src/types'; import { createConfig } from '../../test-utils'; diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index 56a0771f..733e1d40 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -3,6 +3,7 @@ import { CameraManager } from '../../src/camera-manager/manager'; import { ActionsManager } from '../../src/card-controller/actions/actions-manager'; import { AutomationsManager } from '../../src/card-controller/automations-manager'; import { CallManager } from '../../src/card-controller/call/manager'; +import { CameraTriggersManager } from '../../src/card-controller/camera-triggers-manager'; import { CameraURLManager } from '../../src/card-controller/camera-url-manager'; import { CardElementManager, @@ -28,10 +29,9 @@ import { PIPManager } from '../../src/card-controller/pip-manager'; import { QueryStringManager } from '../../src/card-controller/query-string-manager'; import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager'; import { StyleManager } from '../../src/card-controller/style-manager'; -import { TriggersManager } from '../../src/card-controller/triggers-manager'; import { ViewItemManager } from '../../src/card-controller/view/item-manager'; import { ViewManager } from '../../src/card-controller/view/view-manager'; -import { ConditionStateManager } from '../../src/conditions/state-manager'; +import { ConditionStateManager } from '../../src/condition-trigger/conditions/state-manager'; import { AdvancedCameraCardEditor } from '../../src/editor'; import { DeviceRegistryManager } from '../../src/ha/registry/device'; import { EntityRegistryManagerLive } from '../../src/ha/registry/entity'; @@ -64,10 +64,10 @@ vi.mock('../../src/card-controller/issues/issue-manager'); vi.mock('../../src/card-controller/query-string-manager'); vi.mock('../../src/card-controller/status-bar-item-manager'); vi.mock('../../src/card-controller/style-manager'); -vi.mock('../../src/card-controller/triggers-manager'); +vi.mock('../../src/card-controller/camera-triggers-manager'); vi.mock('../../src/card-controller/view/item-manager'); vi.mock('../../src/card-controller/view/view-manager'); -vi.mock('../../src/conditions/state-manager'); +vi.mock('../../src/condition-trigger/conditions/state-manager'); vi.mock('../../src/ha/registry/device'); vi.mock('../../src/ha/registry/entity'); vi.mock('../../src/ha/resolved-media'); @@ -301,9 +301,9 @@ describe('CardController', () => { ); }); - it('should return getTriggersManager', () => { - expect(createController().getTriggersManager()).toBe( - vi.mocked(TriggersManager).mock.instances[0], + it('should return getCameraTriggersManager', () => { + expect(createController().getCameraTriggersManager()).toBe( + vi.mocked(CameraTriggersManager).mock.instances[0], ); }); diff --git a/tests/card-controller/default-manager.test.ts b/tests/card-controller/default-manager.test.ts index 5b110d44..74cba2fc 100644 --- a/tests/card-controller/default-manager.test.ts +++ b/tests/card-controller/default-manager.test.ts @@ -192,9 +192,9 @@ describe('DefaultManager', () => { advanced_camera_card_action: 'default', }, ], - conditions: [ + triggers: [ { - condition: 'interaction', + trigger: 'interaction', interaction: false, }, ], diff --git a/tests/card-controller/folders/ha/media-matcher.test.ts b/tests/card-controller/folders/ha/media-matcher.test.ts index 9c51e05b..8e3cc1e2 100644 --- a/tests/card-controller/folders/ha/media-matcher.test.ts +++ b/tests/card-controller/folders/ha/media-matcher.test.ts @@ -172,12 +172,6 @@ describe('MediaMatcher', () => { is_folder: false, }, }, - advanced_camera_card: { - media: { - title, - is_folder: false, - }, - }, }, ); }); @@ -208,12 +202,6 @@ describe('MediaMatcher', () => { is_folder: false, }, }, - advanced_camera_card: { - media: { - title, - is_folder: false, - }, - }, }, ); }); diff --git a/tests/card-controller/fullscreen/webkit/index.test.ts b/tests/card-controller/fullscreen/webkit/index.test.ts index abadebab..c50db267 100644 --- a/tests/card-controller/fullscreen/webkit/index.test.ts +++ b/tests/card-controller/fullscreen/webkit/index.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { WebkitFullScreenProvider } from '../../../../src/card-controller/fullscreen/webkit'; -import { ConditionStateManager } from '../../../../src/conditions/state-manager'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; import { MediaPlayerController, WebkitHTMLVideoElement } from '../../../../src/types'; import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils'; diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts index c9a15d0a..12c3862f 100644 --- a/tests/card-controller/initialization-manager.test.ts +++ b/tests/card-controller/initialization-manager.test.ts @@ -5,7 +5,7 @@ import { InitializationAspect, InitializationManager, } from '../../src/card-controller/initialization-manager'; -import { ConditionStateManager } from '../../src/conditions/state-manager'; +import { ConditionStateManager } from '../../src/condition-trigger/conditions/state-manager'; import { sideLoadHomeAssistantElements } from '../../src/ha/side-load-ha-elements.js'; import { loadLanguages } from '../../src/localize/localize'; import { Initializer } from '../../src/utils/initializer/initializer'; diff --git a/tests/card-controller/issues/factory.test.ts b/tests/card-controller/issues/factory.test.ts index e29f5de5..1c1ae345 100644 --- a/tests/card-controller/issues/factory.test.ts +++ b/tests/card-controller/issues/factory.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createIssueManager } from '../../../src/card-controller/issues/factory'; import { IssueManager } from '../../../src/card-controller/issues/issue-manager'; -import { ConditionStateManager } from '../../../src/conditions/state-manager'; +import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager'; import { createCardAPI } from '../../test-utils'; describe('createIssueManager', () => { @@ -27,6 +27,7 @@ describe('createIssueManager', () => { const expectedKeys = [ 'config_error', 'config_upgrade', + 'config_upgrade_failure', 'connection', 'initialization', 'legacy_resource', diff --git a/tests/card-controller/issues/issue-manager.test.ts b/tests/card-controller/issues/issue-manager.test.ts index 6ee83e19..1a32bc37 100644 --- a/tests/card-controller/issues/issue-manager.test.ts +++ b/tests/card-controller/issues/issue-manager.test.ts @@ -12,7 +12,7 @@ import { IssueDescription, IssueKey, } from '../../../src/card-controller/issues/types'; -import { ConditionStateManager } from '../../../src/conditions/state-manager'; +import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager'; import { InteractionMode } from '../../../src/config/schema/view'; import { createCardAPI, diff --git a/tests/card-controller/issues/issues/config-upgrade-failure.test.ts b/tests/card-controller/issues/issues/config-upgrade-failure.test.ts new file mode 100644 index 00000000..ca330c82 --- /dev/null +++ b/tests/card-controller/issues/issues/config-upgrade-failure.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConfigUpgradeFailureIssue } from '../../../../src/card-controller/issues/issues/config-upgrade-failure'; +import { hasConfigUpgradeFailures } from '../../../../src/config/management'; +import { RawAdvancedCameraCardConfig } from '../../../../src/config/types'; +import { createCardAPI } from '../../../test-utils'; + +vi.mock('../../../../src/config/management.js'); + +const createAPI = (rawConfig?: RawAdvancedCameraCardConfig) => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getRawConfig).mockReturnValue(rawConfig ?? null); + return api; +}; + +describe('ConfigUpgradeFailureIssue', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should have correct key', () => { + const issue = new ConfigUpgradeFailureIssue(createAPI()); + expect(issue.key).toBe('config_upgrade_failure'); + }); + + it('should detect failures and return a description', async () => { + vi.mocked(hasConfigUpgradeFailures).mockReturnValue(true); + const rawConfig = { type: 'custom:advanced-camera-card' }; + const issue = new ConfigUpgradeFailureIssue(createAPI(rawConfig)); + + await issue.detectStatic(); + + expect(issue.hasIssue()).toBe(true); + expect(hasConfigUpgradeFailures).toBeCalledWith(rawConfig); + expect(issue.getIssue()).toEqual( + expect.objectContaining({ + icon: 'mdi:update', + severity: 'medium', + notification: expect.objectContaining({ + heading: expect.objectContaining({ + icon: 'mdi:update', + severity: 'medium', + }), + }), + }), + ); + }); + + it('should report no failures and no description for a clean config', async () => { + vi.mocked(hasConfigUpgradeFailures).mockReturnValue(false); + const issue = new ConfigUpgradeFailureIssue( + createAPI({ type: 'custom:advanced-camera-card' }), + ); + + await issue.detectStatic(); + + expect(issue.hasIssue()).toBe(false); + expect(issue.getIssue()).toBeNull(); + }); +}); diff --git a/tests/card-controller/pip-manager.test.ts b/tests/card-controller/pip-manager.test.ts index 6abc07c4..17b8afe2 100644 --- a/tests/card-controller/pip-manager.test.ts +++ b/tests/card-controller/pip-manager.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CardController } from '../../src/card-controller/controller'; import { PIPManager } from '../../src/card-controller/pip-manager'; -import { ConditionStateManager } from '../../src/conditions/state-manager'; +import { ConditionStateManager } from '../../src/condition-trigger/conditions/state-manager'; import { MediaPlayerController } from '../../src/types'; import { createCardAPI, createMediaLoadedInfo, flushPromises } from '../test-utils'; diff --git a/tests/card-controller/templates/index.test.ts b/tests/card-controller/templates/index.test.ts index 809fa201..00be0493 100644 --- a/tests/card-controller/templates/index.test.ts +++ b/tests/card-controller/templates/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { TemplateRenderer } from '../../../src/card-controller/templates/index'; -import { createHASS } from '../../test-utils'; +import { createConfig, createHASS, createStateEntity } from '../../test-utils'; describe('TemplateRenderer', () => { describe('renderRecursively', () => { @@ -24,13 +24,13 @@ describe('TemplateRenderer', () => { expect(result).toBe('View: live'); }); - it('should render string templates with full advanced_camera_card context', () => { + it('should render string templates with the acc context', () => { const renderer = new TemplateRenderer(); const hass = createHASS(); const result = renderer.renderRecursively( hass, - '{{ advanced_camera_card.camera }} - {{ advanced_camera_card.view }}', + '{{ acc.camera }} - {{ acc.view }}', { conditionState: { camera: 'camera.front', view: 'clips' }, }, @@ -90,16 +90,45 @@ describe('TemplateRenderer', () => { expect(renderer.renderRecursively(hass, 'hello world')).toBe('hello world'); }); - it('should render with triggerData context', () => { + it('should render with a top-level stock trigger context', () => { const renderer = new TemplateRenderer(); const hass = createHASS(); - const result = renderer.renderRecursively(hass, '{{ acc.trigger.camera.to }}', { - triggerData: { camera: { from: 'camera.front', to: 'camera.backyard' } }, + const result = renderer.renderRecursively(hass, '{{ trigger.to_state.state }}', { + triggerData: { + platform: 'state', + entity_id: 'binary_sensor.door', + to_state: createStateEntity({ state: 'on' }), + }, + }); + expect(result).toBe('on'); + }); + + it('should render with a top-level card trigger context', () => { + const renderer = new TemplateRenderer(); + const hass = createHASS(); + + const result = renderer.renderRecursively(hass, '{{ trigger.to_acc.camera }}', { + triggerData: { + platform: 'acc', + type: 'camera', + from_acc: { camera: 'camera.front' }, + to_acc: { camera: 'camera.backyard' }, + }, }); expect(result).toBe('camera.backyard'); }); + it('should render with config context', () => { + const renderer = new TemplateRenderer(); + const hass = createHASS(); + + const result = renderer.renderRecursively(hass, '{{ acc.config.view.default }}', { + conditionState: { config: createConfig({ view: { default: 'clips' } }) }, + }); + expect(result).toBe('clips'); + }); + it('should render with mediaData context', () => { const renderer = new TemplateRenderer(); const hass = createHASS(); diff --git a/tests/components-lib/timeline/source.test.ts b/tests/components-lib/timeline/source.test.ts index 8be8c51b..70a5190a 100644 --- a/tests/components-lib/timeline/source.test.ts +++ b/tests/components-lib/timeline/source.test.ts @@ -33,7 +33,7 @@ import { AdvancedCameraCardTimelineItem, TimelineDataSource, } from '../../../src/components-lib/timeline/source'; -import { ConditionStateManagerReadonlyInterface } from '../../../src/conditions/types'; +import { ConditionStateManagerReadonlyInterface } from '../../../src/condition-trigger/conditions/types'; import { QuerySource } from '../../../src/query-source'; import { ViewMediaType } from '../../../src/view/item'; import { QueryNode, UnifiedQuery } from '../../../src/view/unified-query'; diff --git a/tests/condition-trigger/common/media-query-watcher.test.ts b/tests/condition-trigger/common/media-query-watcher.test.ts new file mode 100644 index 00000000..11c867f8 --- /dev/null +++ b/tests/condition-trigger/common/media-query-watcher.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MediaQueryWatcher } from '../../../src/condition-trigger/common/media-query-watcher'; + +// @vitest-environment jsdom +describe('MediaQueryWatcher', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should report whether the query matches', () => { + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: true, + } as unknown as MediaQueryList); + + expect(new MediaQueryWatcher('(orientation: landscape)').matches()).toBe(true); + }); + + it('should invoke the callback on a media-query change', () => { + const addEventListener = vi.fn(); + vi.spyOn(window, 'matchMedia').mockReturnValue({ + addEventListener, + removeEventListener: vi.fn(), + } as unknown as MediaQueryList); + + const onChange = vi.fn(); + new MediaQueryWatcher('(orientation: landscape)').subscribe(onChange); + + expect(addEventListener).toHaveBeenCalledWith('change', expect.anything()); + + addEventListener.mock.calls[0][1](); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('should stop listening and ignore changes after teardown', () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + vi.spyOn(window, 'matchMedia').mockReturnValue({ + addEventListener, + removeEventListener, + } as unknown as MediaQueryList); + + const onChange = vi.fn(); + const teardown = new MediaQueryWatcher('(orientation: landscape)').subscribe( + onChange, + ); + const handler = addEventListener.mock.calls[0][1]; + + teardown(); + expect(removeEventListener).toHaveBeenCalledWith('change', expect.anything()); + + handler(); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/condition-trigger/common/time-period.test.ts b/tests/condition-trigger/common/time-period.test.ts new file mode 100644 index 00000000..6d1651f6 --- /dev/null +++ b/tests/condition-trigger/common/time-period.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { TemplateRenderer } from '../../../src/card-controller/templates'; +import { renderTimePeriodToSeconds } from '../../../src/condition-trigger/common/time-period'; +import { createHASS, createStateEntity } from '../../test-utils'; + +// @vitest-environment jsdom +describe('renderTimePeriodToSeconds', () => { + const createRenderer = (): TemplateRenderer => new TemplateRenderer(); + + const createStateWithDelay = (delay: string) => ({ + hass: createHASS({ 'input_number.delay': createStateEntity({ state: delay }) }), + }); + + // Without `hass` the value is parsed as-is, matching HA's `cv.time_period`. + it('should accept a number of seconds', () => { + expect(renderTimePeriodToSeconds(createRenderer(), 5)).toBe(5); + expect(renderTimePeriodToSeconds(createRenderer(), 0)).toBe(0); + }); + + it('should reject a negative period', () => { + expect(renderTimePeriodToSeconds(createRenderer(), -1)).toBeNull(); + expect(renderTimePeriodToSeconds(createRenderer(), { seconds: -5 })).toBeNull(); + }); + + it('should parse a bare numeric string as seconds', () => { + expect(renderTimePeriodToSeconds(createRenderer(), '5')).toBe(5); + }); + + it('should read HH:MM and HH:MM:SS (two parts are hours:minutes, as in HA)', () => { + expect(renderTimePeriodToSeconds(createRenderer(), '01:30')).toBe(5400); + expect(renderTimePeriodToSeconds(createRenderer(), '00:05')).toBe(300); + expect(renderTimePeriodToSeconds(createRenderer(), '00:00:05')).toBe(5); + expect(renderTimePeriodToSeconds(createRenderer(), '1:00:00')).toBe(3600); + }); + + it('should reject unparseable, empty-part, or over-long strings', () => { + expect(renderTimePeriodToSeconds(createRenderer(), 'not-a-duration')).toBeNull(); + expect(renderTimePeriodToSeconds(createRenderer(), '1:ab')).toBeNull(); + expect(renderTimePeriodToSeconds(createRenderer(), '1:2:3:4')).toBeNull(); + expect(renderTimePeriodToSeconds(createRenderer(), '5:')).toBeNull(); + expect(renderTimePeriodToSeconds(createRenderer(), '')).toBeNull(); + }); + + it('should accept a {days, hours, minutes, seconds, milliseconds} dict', () => { + expect(renderTimePeriodToSeconds(createRenderer(), { seconds: 5 })).toBe(5); + expect(renderTimePeriodToSeconds(createRenderer(), { hours: 1, minutes: 30 })).toBe( + 5400, + ); + expect(renderTimePeriodToSeconds(createRenderer(), { days: 1 })).toBe(86400); + expect(renderTimePeriodToSeconds(createRenderer(), { milliseconds: 500 })).toBe(0.5); + expect(renderTimePeriodToSeconds(createRenderer(), {})).toBe(0); + }); + + it('should coerce a numeric-string dict field', () => { + expect(renderTimePeriodToSeconds(createRenderer(), { minutes: '2' })).toBe(120); + expect(renderTimePeriodToSeconds(createRenderer(), { minutes: 'nope' })).toBeNull(); + }); + + // With `hass`, templates are rendered against the current state before parsing. + it('should render and parse a template string', () => { + expect( + renderTimePeriodToSeconds( + createRenderer(), + "{{ states('input_number.delay') }}", + createStateWithDelay('5'), + ), + ).toBe(5); + }); + + it('should render and parse template fields in a dict', () => { + expect( + renderTimePeriodToSeconds( + createRenderer(), + { minutes: "{{ states('input_number.delay') }}" }, + createStateWithDelay('2'), + ), + ).toBe(120); + }); + + it('should return null when a template renders to a non-duration value', () => { + // A string that is not a number. + expect( + renderTimePeriodToSeconds( + createRenderer(), + "{{ states('input_number.delay') }}", + createStateWithDelay('nope'), + ), + ).toBeNull(); + // A value that is neither a number, string nor dict (here, a boolean). + expect( + renderTimePeriodToSeconds( + createRenderer(), + "{{ is_state('input_number.delay', 'x') }}", + createStateWithDelay('5'), + ), + ).toBeNull(); + }); +}); diff --git a/tests/condition-trigger/conditions/conditions-manager.test.ts b/tests/condition-trigger/conditions/conditions-manager.test.ts new file mode 100644 index 00000000..74968b43 --- /dev/null +++ b/tests/condition-trigger/conditions/conditions-manager.test.ts @@ -0,0 +1,297 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ConditionsManager } from '../../../src/condition-trigger/conditions/conditions-manager'; +import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager'; +import { createHASS, createStateEntity } from '../../test-utils'; + +// Per-condition-type evaluation is covered by tests/conditions/conditions/.test.ts. +// This file covers the manager's own orchestration: building/destroying evaluators, +// listener management, trigger-data merging, and notifying (or not) on changes. + +// @vitest-environment jsdom +describe('ConditionsManager', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should not call listeners for HA state changes without a relevant condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + + stateManager.setState({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), + }); + + expect(listener).not.toBeCalled(); + }); + + it('should forward the triggering state change to listeners', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'fullscreen' as const, fullscreen: true }], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + + stateManager.setState({ fullscreen: true }); + + // The change that prompted the evaluation is passed through verbatim. + expect(listener).toHaveBeenLastCalledWith(expect.anything(), { + old: {}, + change: { fullscreen: true }, + new: { fullscreen: true }, + }); + }); + + it('should re-evaluate and notify when a subscribed condition source changes', () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + vi.spyOn(window, 'matchMedia') + .mockReturnValueOnce({ + addEventListener: addEventListener, + removeEventListener: removeEventListener, + } as unknown as MediaQueryList) + .mockReturnValueOnce({ + matches: false, + } as unknown as MediaQueryList) + .mockReturnValueOnce({ + matches: true, + } as unknown as MediaQueryList); + + const manager = new ConditionsManager([ + { condition: 'screen' as const, media_query: 'whatever' }, + ]); + + const listener = vi.fn(); + manager.addListener(listener); + + // Fire the media-query change; the manager re-evaluates and notifies. + addEventListener.mock.calls[0][1](); + expect(listener).toBeCalledWith({ result: true }, undefined); + + // Destroy tears the subscription down. + manager.destroy(); + expect(removeEventListener).toBeCalled(); + }); + + describe('should handle listeners correctly', () => { + it('should add listener', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'fullscreen' as const, fullscreen: true }], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + + stateManager.setState({ fullscreen: true }); + + expect(listener).toBeCalledWith({ result: true }, expect.anything()); + expect(listener).toBeCalledTimes(1); + + stateManager.setState({ fullscreen: false }); + expect(listener).toBeCalledWith({ result: false }, expect.anything()); + expect(listener).toBeCalledTimes(2); + + // Re-add the same listener (will still only be called once). + manager.addListener(listener); + + stateManager.setState({ fullscreen: true }); + + expect(listener).toBeCalledWith({ result: true }, expect.anything()); + expect(listener).toBeCalledTimes(3); + }); + + it('should remove listener', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'fullscreen' as const, fullscreen: true }], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + manager.removeListener(listener); + + stateManager.setState({ fullscreen: true }); + + expect(listener).not.toBeCalled(); + }); + + it('should remove listener on destroy', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'fullscreen' as const, fullscreen: true }], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + manager.destroy(); + + stateManager.setState({ fullscreen: true }); + + expect(listener).not.toBeCalled(); + }); + + it('should not call listeners when the condition result does not change', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'view' as const, views: ['live'] }], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + + stateManager.setState({ view: 'live' }); + expect(listener).toBeCalledTimes(1); + + stateManager.setState({ view: 'clip' }); + expect(listener).toBeCalledTimes(2); + + stateManager.setState({ view: 'clip' }); + expect(listener).toBeCalledTimes(2); + + stateManager.setState({ view: 'live' }); + expect(listener).toBeCalledTimes(3); + + stateManager.setState({ view: 'live' }); + expect(listener).toBeCalledTimes(3); + }); + }); + + describe('enabled', () => { + const ENABLED_TEMPLATE = '{{ is_state("binary_sensor.flag", "on") }}'; + + it('should ignore a disabled condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'fullscreen' as const, fullscreen: true, enabled: false }], + stateManager, + ); + + // The disabled condition is ignored, so with no remaining conditions the + // result is true even though fullscreen does not match. + stateManager.setState({ fullscreen: false }); + + expect(manager.getEvaluation()).toEqual({ result: true }); + }); + + it('should evaluate an enabled condition normally', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'fullscreen' as const, fullscreen: true, enabled: true }], + stateManager, + ); + + stateManager.setState({ fullscreen: false }); + expect(manager.getEvaluation()).toEqual({ result: false }); + + stateManager.setState({ fullscreen: true }); + expect(manager.getEvaluation()).toEqual({ result: true }); + }); + + it('should drop a condition whose enabled template does not render true', () => { + const stateManager = new ConditionStateManager(); + stateManager.setState({ + hass: createHASS({ 'binary_sensor.flag': createStateEntity({ state: 'off' }) }), + }); + const manager = new ConditionsManager( + [ + { + condition: 'fullscreen' as const, + fullscreen: true, + enabled: ENABLED_TEMPLATE, + }, + ], + stateManager, + ); + + stateManager.setState({ fullscreen: false }); + + expect(manager.getEvaluation()).toEqual({ result: true }); + }); + + it('should evaluate a condition whose enabled template renders true', () => { + const stateManager = new ConditionStateManager(); + stateManager.setState({ + hass: createHASS({ 'binary_sensor.flag': createStateEntity({ state: 'on' }) }), + }); + const manager = new ConditionsManager( + [ + { + condition: 'fullscreen' as const, + fullscreen: true, + enabled: ENABLED_TEMPLATE, + }, + ], + stateManager, + ); + + stateManager.setState({ fullscreen: false }); + + expect(manager.getEvaluation()).toEqual({ result: false }); + }); + + it('should keep a condition enabled when its template cannot render without hass', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'fullscreen' as const, + fullscreen: true, + enabled: ENABLED_TEMPLATE, + }, + ], + stateManager, + ); + + stateManager.setState({ fullscreen: false }); + + // With no hass the enabled template cannot render, so the gate stays + // enabled and the condition is evaluated (here: fullscreen is false). + expect(manager.getEvaluation()).toEqual({ result: false }); + }); + + it('should re-evaluate the enabled template on each evaluation', () => { + const stateManager = new ConditionStateManager(); + stateManager.setState({ + hass: createHASS({ 'binary_sensor.flag': createStateEntity({ state: 'off' }) }), + fullscreen: false, + }); + const manager = new ConditionsManager( + [ + { + condition: 'fullscreen' as const, + fullscreen: true, + enabled: ENABLED_TEMPLATE, + }, + ], + stateManager, + ); + + // Flag off: the condition is disabled and ignored, so the result is true. + expect(manager.getEvaluation()).toEqual({ result: true }); + + // Flag on: the condition is now active and fullscreen does not match. + stateManager.setState({ + hass: createHASS({ 'binary_sensor.flag': createStateEntity({ state: 'on' }) }), + }); + expect(manager.getEvaluation()).toEqual({ result: false }); + }); + }); +}); diff --git a/tests/condition-trigger/conditions/conditions/and.test.ts b/tests/condition-trigger/conditions/conditions/and.test.ts new file mode 100644 index 00000000..89e28738 --- /dev/null +++ b/tests/condition-trigger/conditions/conditions/and.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('and condition', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should evaluate a simple and condition', () => { + const evaluator = createConditionEvaluator( + { + condition: 'and' as const, + conditions: [ + { condition: 'fullscreen' as const, fullscreen: true }, + { condition: 'expand' as const, expand: true }, + ], + }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ fullscreen: true }).result).toBeFalsy(); + expect(evaluator.evaluate({ fullscreen: true, expand: true }).result).toBeTruthy(); + expect(evaluator.evaluate({ fullscreen: false, expand: true }).result).toBeFalsy(); + }); + + it('should expose its children external invalidation sources', () => { + // The `screen` child contributes an external source; the `fullscreen` child + // contributes none -- the union must include only the former. + const evaluator = createConditionEvaluator( + { + condition: 'and' as const, + conditions: [ + { condition: 'screen' as const, media_query: 'whatever' }, + { condition: 'fullscreen' as const, fullscreen: true }, + ], + }, + createEvaluatorContext(), + ); + + expect(evaluator.externalSources).toHaveLength(1); + }); +}); diff --git a/tests/conditions/conditions/call.test.ts b/tests/condition-trigger/conditions/conditions/call.test.ts similarity index 69% rename from tests/conditions/conditions/call.test.ts rename to tests/condition-trigger/conditions/conditions/call.test.ts index ec8dbbb9..d8b8d7a5 100644 --- a/tests/conditions/conditions/call.test.ts +++ b/tests/condition-trigger/conditions/conditions/call.test.ts @@ -1,18 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; +import { callConditionSchema } from '../../../../src/config/schema/condition-trigger/conditions/custom/call'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom describe('call condition', () => { - it('should default to call true in its bare form', () => { - const evaluator = createConditionEvaluator( - { condition: 'call' as const }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({}).result).toBeFalsy(); - expect(evaluator.evaluate({ call: true }).result).toBeTruthy(); - expect(evaluator.evaluate({ call: false }).result).toBeFalsy(); + it('should require a value', () => { + expect(() => callConditionSchema.parse({ condition: 'call' })).toThrow(); }); it('should match when call is true', () => { diff --git a/tests/condition-trigger/conditions/conditions/camera.test.ts b/tests/condition-trigger/conditions/conditions/camera.test.ts new file mode 100644 index 00000000..1637907d --- /dev/null +++ b/tests/condition-trigger/conditions/conditions/camera.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('camera condition', () => { + it('should match a named camera', () => { + const evaluator = createConditionEvaluator( + { condition: 'camera' as const, cameras: ['bar'] }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ camera: 'bar' }).result).toBeTruthy(); + expect(evaluator.evaluate({ camera: 'will-not-match' }).result).toBeFalsy(); + }); + + it('should match any of several named cameras', () => { + const evaluator = createConditionEvaluator( + { condition: 'camera' as const, cameras: ['foo', 'bar'] }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({ camera: 'bar' }).result).toBeTruthy(); + expect(evaluator.evaluate({ camera: 'foo' }).result).toBeTruthy(); + expect(evaluator.evaluate({ camera: 'baz' }).result).toBeFalsy(); + }); + + it('should match a selected camera when cameras is omitted', () => { + const evaluator = createConditionEvaluator( + { condition: 'camera' as const }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({ camera: 'bar' }).result).toBeTruthy(); + expect(evaluator.evaluate({}).result).toBeFalsy(); + }); + + it('should match no selected camera for an empty list', () => { + const evaluator = createConditionEvaluator( + { condition: 'camera' as const, cameras: [] }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeTruthy(); + expect(evaluator.evaluate({ camera: 'bar' }).result).toBeFalsy(); + }); +}); diff --git a/tests/conditions/conditions/display-mode.test.ts b/tests/condition-trigger/conditions/conditions/display-mode.test.ts similarity index 86% rename from tests/conditions/conditions/display-mode.test.ts rename to tests/condition-trigger/conditions/conditions/display-mode.test.ts index 24af7c5f..78b1ed5f 100644 --- a/tests/conditions/conditions/display-mode.test.ts +++ b/tests/condition-trigger/conditions/conditions/display-mode.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom diff --git a/tests/conditions/conditions/expand.test.ts b/tests/condition-trigger/conditions/conditions/expand.test.ts similarity index 62% rename from tests/conditions/conditions/expand.test.ts rename to tests/condition-trigger/conditions/conditions/expand.test.ts index 42e9a8ca..1dc8d1fc 100644 --- a/tests/conditions/conditions/expand.test.ts +++ b/tests/condition-trigger/conditions/conditions/expand.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; +import { expandConditionSchema } from '../../../../src/config/schema/condition-trigger/conditions/custom/expand'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom @@ -14,4 +15,8 @@ describe('expand condition', () => { expect(evaluator.evaluate({ expand: true }).result).toBeTruthy(); expect(evaluator.evaluate({ expand: false }).result).toBeFalsy(); }); + + it('should require a value', () => { + expect(() => expandConditionSchema.parse({ condition: 'expand' })).toThrow(); + }); }); diff --git a/tests/conditions/conditions/fullscreen.test.ts b/tests/condition-trigger/conditions/conditions/fullscreen.test.ts similarity index 62% rename from tests/conditions/conditions/fullscreen.test.ts rename to tests/condition-trigger/conditions/conditions/fullscreen.test.ts index 90ff16b7..54b5dc6c 100644 --- a/tests/conditions/conditions/fullscreen.test.ts +++ b/tests/condition-trigger/conditions/conditions/fullscreen.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; +import { fullscreenConditionSchema } from '../../../../src/config/schema/condition-trigger/conditions/custom/fullscreen'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom @@ -14,4 +15,8 @@ describe('fullscreen condition', () => { expect(evaluator.evaluate({ fullscreen: true }).result).toBeTruthy(); expect(evaluator.evaluate({ fullscreen: false }).result).toBeFalsy(); }); + + it('should require a value', () => { + expect(() => fullscreenConditionSchema.parse({ condition: 'fullscreen' })).toThrow(); + }); }); diff --git a/tests/conditions/conditions/initialized.test.ts b/tests/condition-trigger/conditions/conditions/initialized.test.ts similarity index 85% rename from tests/conditions/conditions/initialized.test.ts rename to tests/condition-trigger/conditions/conditions/initialized.test.ts index eda4f0a4..ad2bb86d 100644 --- a/tests/conditions/conditions/initialized.test.ts +++ b/tests/condition-trigger/conditions/conditions/initialized.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom diff --git a/tests/conditions/conditions/interaction.test.ts b/tests/condition-trigger/conditions/conditions/interaction.test.ts similarity index 61% rename from tests/conditions/conditions/interaction.test.ts rename to tests/condition-trigger/conditions/conditions/interaction.test.ts index 60a512a0..93a04857 100644 --- a/tests/conditions/conditions/interaction.test.ts +++ b/tests/condition-trigger/conditions/conditions/interaction.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; +import { interactionConditionSchema } from '../../../../src/config/schema/condition-trigger/conditions/custom/interaction'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom @@ -14,4 +15,10 @@ describe('interaction condition', () => { expect(evaluator.evaluate({ interaction: true }).result).toBeTruthy(); expect(evaluator.evaluate({ interaction: false }).result).toBeFalsy(); }); + + it('should require a value', () => { + expect(() => + interactionConditionSchema.parse({ condition: 'interaction' }), + ).toThrow(); + }); }); diff --git a/tests/condition-trigger/conditions/conditions/is-template-true.test.ts b/tests/condition-trigger/conditions/conditions/is-template-true.test.ts new file mode 100644 index 00000000..18321e6e --- /dev/null +++ b/tests/condition-trigger/conditions/conditions/is-template-true.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { isTemplateTrue } from '../../../../src/condition-trigger/conditions/conditions/is-template-true'; + +describe('condition isTemplateTrue', () => { + it('should accept boolean true', () => { + expect(isTemplateTrue(true)).toBe(true); + }); + + it('should accept the string "true" case-insensitively', () => { + expect(isTemplateTrue('true')).toBe(true); + expect(isTemplateTrue('TRUE')).toBe(true); + }); + + it('should reject other truthy-looking values for HA symmetry', () => { + for (const value of ['yes', 'on', '1', 'enable', false, 0, null, undefined]) { + expect(isTemplateTrue(value)).toBe(false); + } + }); +}); diff --git a/tests/conditions/conditions/key.test.ts b/tests/condition-trigger/conditions/conditions/key.test.ts similarity index 94% rename from tests/conditions/conditions/key.test.ts rename to tests/condition-trigger/conditions/conditions/key.test.ts index 0c75f053..bd7905df 100644 --- a/tests/conditions/conditions/key.test.ts +++ b/tests/condition-trigger/conditions/conditions/key.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom diff --git a/tests/conditions/conditions/media-loaded.test.ts b/tests/condition-trigger/conditions/conditions/media-loaded.test.ts similarity index 59% rename from tests/conditions/conditions/media-loaded.test.ts rename to tests/condition-trigger/conditions/conditions/media-loaded.test.ts index 53c4b22c..ca0fe289 100644 --- a/tests/conditions/conditions/media-loaded.test.ts +++ b/tests/condition-trigger/conditions/conditions/media-loaded.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; -import { createMediaLoadedInfo } from '../../test-utils'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; +import { mediaLoadedConditionSchema } from '../../../../src/config/schema/condition-trigger/conditions/custom/media-loaded'; +import { createMediaLoadedInfo } from '../../../test-utils'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom @@ -17,4 +18,10 @@ describe('media loaded condition', () => { ).toBeTruthy(); expect(evaluator.evaluate({ mediaLoadedInfo: null }).result).toBeFalsy(); }); + + it('should require a value', () => { + expect(() => + mediaLoadedConditionSchema.parse({ condition: 'media_loaded' }), + ).toThrow(); + }); }); diff --git a/tests/conditions/conditions/microphone.test.ts b/tests/condition-trigger/conditions/conditions/microphone.test.ts similarity index 88% rename from tests/conditions/conditions/microphone.test.ts rename to tests/condition-trigger/conditions/conditions/microphone.test.ts index 2418be47..dc4ce7c2 100644 --- a/tests/conditions/conditions/microphone.test.ts +++ b/tests/condition-trigger/conditions/conditions/microphone.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { MicrophoneState } from '../../../src/card-controller/types'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { MicrophoneState } from '../../../../src/card-controller/types'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom diff --git a/tests/conditions/conditions/not.test.ts b/tests/condition-trigger/conditions/conditions/not.test.ts similarity index 55% rename from tests/conditions/conditions/not.test.ts rename to tests/condition-trigger/conditions/conditions/not.test.ts index 0fa2e735..bc5c60c1 100644 --- a/tests/conditions/conditions/not.test.ts +++ b/tests/condition-trigger/conditions/conditions/not.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom @@ -20,28 +20,20 @@ describe('not condition', () => { createEvaluatorContext(), ); - // Neither sub-condition is true, so `not` passes — and reports no trigger - // data (nothing is "triggering"). + // Neither sub-condition is true, so `not` passes. expect(evaluator.evaluate({})).toEqual({ result: true }); // Any sub-condition being true means `not` fails. expect(evaluator.evaluate({ fullscreen: true }).result).toBeFalsy(); expect(evaluator.evaluate({ expand: true }).result).toBeFalsy(); - // Both sub-conditions false again — `not` passes. + // Both sub-conditions false again -- `not` passes. expect(evaluator.evaluate({ fullscreen: false, expand: false }).result).toBeTruthy(); }); - it('should forward subscribe and destroy to its children', () => { - const addEventListener = vi.fn(); - const removeEventListener = vi.fn(); - vi.spyOn(window, 'matchMedia').mockReturnValue({ - addEventListener: addEventListener, - removeEventListener: removeEventListener, - } as unknown as MediaQueryList); - - // The `screen` child supports subscribe/destroy; the `fullscreen` child does - // not — forwarding must handle both. + it('should expose its children external invalidation sources', () => { + // The `screen` child contributes an external source; the `fullscreen` child + // contributes none -- the union must include only the former. const evaluator = createConditionEvaluator( { condition: 'not' as const, @@ -53,13 +45,6 @@ describe('not condition', () => { createEvaluatorContext(), ); - const onChange = vi.fn(); - evaluator.subscribe?.(onChange); - expect(addEventListener).toHaveBeenCalledWith('change', expect.anything()); - addEventListener.mock.calls[0][1](); - expect(onChange).toHaveBeenCalled(); - - evaluator.destroy?.(); - expect(removeEventListener).toHaveBeenCalled(); + expect(evaluator.externalSources).toHaveLength(1); }); }); diff --git a/tests/condition-trigger/conditions/conditions/numeric-state.test.ts b/tests/condition-trigger/conditions/conditions/numeric-state.test.ts new file mode 100644 index 00000000..8fb81ee6 --- /dev/null +++ b/tests/condition-trigger/conditions/conditions/numeric-state.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; +import { createHASS, createStateEntity } from '../../../test-utils'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('numeric state condition', () => { + it('should match above a threshold', () => { + const evaluator = createConditionEvaluator( + { condition: 'numeric_state' as const, entity_id: 'sensor.foo', above: 10 }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), + }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: '9' }) }), + }).result, + ).toBeFalsy(); + }); + + it('should match below a threshold', () => { + const evaluator = createConditionEvaluator( + { condition: 'numeric_state' as const, entity_id: 'sensor.foo', below: 10 }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), + }).result, + ).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '9' }) }), + }).result, + ).toBeTruthy(); + }); + + it('should accept the entity field', () => { + const evaluator = createConditionEvaluator( + { condition: 'numeric_state' as const, entity: 'sensor.foo', above: 10 }, + createEvaluatorContext(), + ); + + expect( + evaluator.evaluate({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), + }).result, + ).toBeTruthy(); + }); + + it('should match a list of entities only when all of them match', () => { + const evaluator = createConditionEvaluator( + { + condition: 'numeric_state' as const, + entity_id: ['sensor.a', 'sensor.b'], + above: 10, + }, + createEvaluatorContext(), + ); + + // Both above -> match. + expect( + evaluator.evaluate({ + hass: createHASS({ + 'sensor.a': createStateEntity({ state: '11' }), + 'sensor.b': createStateEntity({ state: '12' }), + }), + }).result, + ).toBeTruthy(); + // One below -> no match. + expect( + evaluator.evaluate({ + hass: createHASS({ + 'sensor.a': createStateEntity({ state: '11' }), + 'sensor.b': createStateEntity({ state: '9' }), + }), + }).result, + ).toBeFalsy(); + // One absent -> no match. + expect( + evaluator.evaluate({ + hass: createHASS({ + 'sensor.a': createStateEntity({ state: '11' }), + }), + }).result, + ).toBeFalsy(); + }); + + it('should match against an attribute instead of the state', () => { + const evaluator = createConditionEvaluator( + { + condition: 'numeric_state' as const, + entity_id: 'sensor.foo', + attribute: 'battery', + below: 20, + }, + createEvaluatorContext(), + ); + + expect( + evaluator.evaluate({ + hass: createHASS({ + 'sensor.foo': createStateEntity({ state: 'on', attributes: { battery: 15 } }), + }), + }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ + hass: createHASS({ + 'sensor.foo': createStateEntity({ state: 'on', attributes: { battery: 50 } }), + }), + }).result, + ).toBeFalsy(); + }); + + it('should compare the rendered value_template instead of the state', () => { + const evaluator = createConditionEvaluator( + { + condition: 'numeric_state' as const, + entity_id: 'sensor.foo', + value_template: '{{ 11 }}', + above: 10, + }, + createEvaluatorContext(), + ); + + // The entity state (0) would fail; the template value (11) passes. + expect( + evaluator.evaluate({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '0' }) }), + }).result, + ).toBeTruthy(); + }); + + it('should not match when the value is not numeric', () => { + const evaluator = createConditionEvaluator( + { condition: 'numeric_state' as const, entity_id: 'sensor.foo', above: 10 }, + createEvaluatorContext(), + ); + + expect( + evaluator.evaluate({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: 'unavailable' }) }), + }).result, + ).toBeFalsy(); + }); + + it('should not match without a condition state', () => { + const evaluator = createConditionEvaluator( + { condition: 'numeric_state' as const, entity_id: 'sensor.foo', above: 10 }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate().result).toBeFalsy(); + }); + + it('should not match when the entity is absent', () => { + const evaluator = createConditionEvaluator( + { condition: 'numeric_state' as const, entity_id: 'sensor.missing', above: 10 }, + createEvaluatorContext(), + ); + + expect( + evaluator.evaluate({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), + }).result, + ).toBeFalsy(); + }); + + it('should resolve an entity-id reference as the above/below threshold', () => { + const evaluator = createConditionEvaluator( + { + condition: 'numeric_state' as const, + entity_id: 'sensor.foo', + above: 'input_number.limit', + }, + createEvaluatorContext(), + ); + + // sensor.foo (20) > input_number.limit (10) -> true. + expect( + evaluator.evaluate({ + hass: createHASS({ + 'sensor.foo': createStateEntity({ state: '20' }), + 'input_number.limit': createStateEntity({ state: '10' }), + }), + }).result, + ).toBeTruthy(); + // sensor.foo (5) > input_number.limit (10) -> false. + expect( + evaluator.evaluate({ + hass: createHASS({ + 'sensor.foo': createStateEntity({ state: '5' }), + 'input_number.limit': createStateEntity({ state: '10' }), + }), + }).result, + ).toBeFalsy(); + }); + + it('should not match when a threshold entity is unresolvable', () => { + const evaluator = createConditionEvaluator( + { + condition: 'numeric_state' as const, + entity_id: 'sensor.foo', + below: 'input_number.limit', + }, + createEvaluatorContext(), + ); + + expect( + evaluator.evaluate({ + hass: createHASS({ + 'sensor.foo': createStateEntity({ state: '5' }), + 'input_number.limit': createStateEntity({ state: 'unavailable' }), + }), + }).result, + ).toBeFalsy(); + }); +}); diff --git a/tests/condition-trigger/conditions/conditions/or.test.ts b/tests/condition-trigger/conditions/conditions/or.test.ts new file mode 100644 index 00000000..6f728457 --- /dev/null +++ b/tests/condition-trigger/conditions/conditions/or.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('or condition', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should evaluate a simple or condition', () => { + const evaluator = createConditionEvaluator( + { + condition: 'or' as const, + conditions: [ + { condition: 'fullscreen' as const, fullscreen: true }, + { condition: 'expand' as const, expand: true }, + ], + }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ fullscreen: true }).result).toBeTruthy(); + expect(evaluator.evaluate({ expand: true }).result).toBeTruthy(); + expect(evaluator.evaluate({ fullscreen: false, expand: false }).result).toBeFalsy(); + }); + + it('should expose its children external invalidation sources', () => { + // The `screen` child contributes an external source; the `fullscreen` child + // contributes none -- the union must include only the former. + const evaluator = createConditionEvaluator( + { + condition: 'or' as const, + conditions: [ + { condition: 'screen' as const, media_query: 'whatever' }, + { condition: 'fullscreen' as const, fullscreen: true }, + ], + }, + createEvaluatorContext(), + ); + + expect(evaluator.externalSources).toHaveLength(1); + }); +}); diff --git a/tests/condition-trigger/conditions/conditions/screen.test.ts b/tests/condition-trigger/conditions/conditions/screen.test.ts new file mode 100644 index 00000000..935740a2 --- /dev/null +++ b/tests/condition-trigger/conditions/conditions/screen.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('screen condition', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should evaluate the media query', () => { + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: true, + } as unknown as MediaQueryList); + + const evaluator = createConditionEvaluator( + { condition: 'screen' as const, media_query: 'whatever' }, + createEvaluatorContext(), + ); + expect(evaluator.evaluate().result).toBeTruthy(); + }); + + it('should expose the media query as an external invalidation source', () => { + const evaluator = createConditionEvaluator( + { condition: 'screen' as const, media_query: 'whatever' }, + createEvaluatorContext(), + ); + expect(evaluator.externalSources).toHaveLength(1); + }); + + it('should not match or expose a source without a media query', () => { + const matchMedia = vi.spyOn(window, 'matchMedia'); + const evaluator = createConditionEvaluator( + { condition: 'screen' as const }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate().result).toBeFalsy(); + expect(evaluator.externalSources).toHaveLength(0); + expect(matchMedia).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/condition-trigger/conditions/conditions/state.test.ts b/tests/condition-trigger/conditions/conditions/state.test.ts new file mode 100644 index 00000000..bb900e72 --- /dev/null +++ b/tests/condition-trigger/conditions/conditions/state.test.ts @@ -0,0 +1,472 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; +import { createHASS, createStateEntity } from '../../../test-utils'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('state condition', () => { + it('should match any transition when neither state nor state_not is set', () => { + const evaluator = createConditionEvaluator( + { condition: 'state' as const, entity_id: 'binary_sensor.foo' }, + createEvaluatorContext(), + ); + + expect( + evaluator.evaluate( + { + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'on' }) }), + }, + {}, + ), + ).toEqual({ result: true }); + + expect( + evaluator.evaluate( + { + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), + }, + { + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'on' }) }), + }, + ), + ).toEqual({ result: true }); + }); + + it('should match a single positive state', () => { + const evaluator = createConditionEvaluator( + { condition: 'state' as const, entity_id: 'binary_sensor.foo', state: 'on' }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), + }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), + }).result, + ).toBeFalsy(); + }); + + it('should match multiple positive states', () => { + const evaluator = createConditionEvaluator( + { + condition: 'state' as const, + entity_id: 'binary_sensor.foo', + state: ['active', 'on'], + }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), + }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'active' }), + }), + }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), + }).result, + ).toBeFalsy(); + }); + + it('should match a single negative state', () => { + const evaluator = createConditionEvaluator( + { condition: 'state' as const, entity_id: 'binary_sensor.foo', state_not: 'on' }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), + }).result, + ).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), + }).result, + ).toBeTruthy(); + }); + + it('should match multiple negative states', () => { + const evaluator = createConditionEvaluator( + { + condition: 'state' as const, + entity_id: 'binary_sensor.foo', + state_not: ['active', 'on'], + }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), + }).result, + ).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'active' }), + }), + }).result, + ).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), + }).result, + ).toBeTruthy(); + }); + + it('should match an implicit state condition', () => { + const evaluator = createConditionEvaluator( + { entity_id: 'binary_sensor.foo', state: 'on' }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), + }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), + }).result, + ).toBeFalsy(); + }); + + it('should not match when no entity is set', () => { + const evaluator = createConditionEvaluator( + { condition: 'state' as const, state: 'on' }, + createEvaluatorContext(), + ); + + const result = evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'on' }) }), + }); + expect(result.result).toBeFalsy(); + }); + + it('should accept the entity field', () => { + const evaluator = createConditionEvaluator( + { condition: 'state' as const, entity: 'binary_sensor.foo', state: 'on' }, + createEvaluatorContext(), + ); + + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'on' }) }), + }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), + }).result, + ).toBeFalsy(); + }); + + it('should match a list of entities only when all match by default', () => { + const evaluator = createConditionEvaluator( + { + condition: 'state' as const, + entity_id: ['binary_sensor.foo', 'binary_sensor.bar'], + state: 'on', + }, + createEvaluatorContext(), + ); + + // Both on -> match. + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'on' }), + 'binary_sensor.bar': createStateEntity({ state: 'on' }), + }), + }).result, + ).toBeTruthy(); + // One off -> no match. + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'on' }), + 'binary_sensor.bar': createStateEntity({ state: 'off' }), + }), + }).result, + ).toBeFalsy(); + // One absent -> no match. + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'on' }), + }), + }).result, + ).toBeFalsy(); + }); + + it('should match a list of entities when any matches and match is any', () => { + const evaluator = createConditionEvaluator( + { + condition: 'state' as const, + entity_id: ['binary_sensor.foo', 'binary_sensor.bar'], + state: 'on', + match: 'any' as const, + }, + createEvaluatorContext(), + ); + + // One on -> match. + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'on' }), + 'binary_sensor.bar': createStateEntity({ state: 'off' }), + }), + }).result, + ).toBeTruthy(); + // None on -> no match. + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'off' }), + 'binary_sensor.bar': createStateEntity({ state: 'off' }), + }), + }).result, + ).toBeFalsy(); + }); + + it('should match against an attribute instead of the state', () => { + const evaluator = createConditionEvaluator( + { + condition: 'state' as const, + entity_id: 'binary_sensor.foo', + attribute: 'device_class', + state: 'door', + }, + createEvaluatorContext(), + ); + + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ + state: 'on', + attributes: { device_class: 'door' }, + }), + }), + }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ + state: 'on', + attributes: { device_class: 'window' }, + }), + }), + }).result, + ).toBeFalsy(); + // Attribute absent on the entity -> no value -> no match. + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'on', attributes: {} }), + }), + }).result, + ).toBeFalsy(); + }); + + describe('for', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-05T22:56:56Z')); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('should not match until the state has been held long enough', () => { + const evaluator = createConditionEvaluator( + { + condition: 'state' as const, + entity_id: 'binary_sensor.foo', + state: 'on', + for: '00:00:05', + }, + createEvaluatorContext(), + ); + + // Held 2s (< 5s) -> no match. + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ + state: 'on', + last_changed: '2026-06-05T22:56:54Z', + }), + }), + }).result, + ).toBeFalsy(); + + // Held 8s (>= 5s) -> match. + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ + state: 'on', + last_changed: '2026-06-05T22:56:48Z', + }), + }), + }).result, + ).toBeTruthy(); + }); + + it('should render a templated "for" before comparing', () => { + const evaluator = createConditionEvaluator( + { + condition: 'state' as const, + entity_id: 'binary_sensor.foo', + state: 'on', + for: "{{ states('input_number.delay') }}", + }, + createEvaluatorContext(), + ); + + const evaluateHeldSince = (lastChanged: string): boolean => + !!evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ + state: 'on', + last_changed: lastChanged, + }), + 'input_number.delay': createStateEntity({ state: '5' }), + }), + }).result; + + // `for` renders to 5s: held 2s -> no match, held 8s -> match. + expect(evaluateHeldSince('2026-06-05T22:56:54Z')).toBe(false); + expect(evaluateHeldSince('2026-06-05T22:56:48Z')).toBe(true); + }); + + it('should not match when last_changed is unavailable', () => { + const evaluator = createConditionEvaluator( + { + condition: 'state' as const, + entity_id: 'binary_sensor.foo', + state: 'on', + for: '00:00:05', + }, + createEvaluatorContext(), + ); + + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'on', last_changed: '' }), + }), + }).result, + ).toBeFalsy(); + }); + + it('should not match when for is unparseable', () => { + const evaluator = createConditionEvaluator( + { + condition: 'state' as const, + entity_id: 'binary_sensor.foo', + state: 'on', + for: 'not-a-duration', + }, + createEvaluatorContext(), + ); + + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ + state: 'on', + last_changed: '2026-06-05T22:56:46Z', + }), + }), + }).result, + ).toBeFalsy(); + }); + }); + + it('should resolve an expected state that names another entity', () => { + const evaluator = createConditionEvaluator( + { + condition: 'state' as const, + entity_id: 'binary_sensor.foo', + state: 'input_text.expected', + }, + createEvaluatorContext(), + ); + + // The entity's state matches the resolved state of `input_text.expected`. + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'armed' }), + 'input_text.expected': createStateEntity({ state: 'armed' }), + }), + }).result, + ).toBeTruthy(); + + // It does not match when the resolved state differs. + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'disarmed' }), + 'input_text.expected': createStateEntity({ state: 'armed' }), + }), + }).result, + ).toBeFalsy(); + }); + + it('should match a state_not against an empty-string entity state', () => { + const evaluator = createConditionEvaluator( + { condition: 'state' as const, entity_id: 'sensor.foo', state_not: 'on' }, + createEvaluatorContext(), + ); + + expect( + evaluator.evaluate({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '' }) }), + }).result, + ).toBeTruthy(); + }); + + it('should match an empty-string expected state', () => { + const evaluator = createConditionEvaluator( + { condition: 'state' as const, entity_id: 'sensor.foo', state: '' }, + createEvaluatorContext(), + ); + + // An entity state of "" matches the configured empty `state`. + expect( + evaluator.evaluate({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '' }) }), + }).result, + ).toBeTruthy(); + + // A non-empty entity state does not. + expect( + evaluator.evaluate({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: 'on' }) }), + }).result, + ).toBeFalsy(); + }); +}); diff --git a/tests/conditions/conditions/template.test.ts b/tests/condition-trigger/conditions/conditions/template.test.ts similarity index 72% rename from tests/conditions/conditions/template.test.ts rename to tests/condition-trigger/conditions/conditions/template.test.ts index 7150cbd1..74ef7f1e 100644 --- a/tests/conditions/conditions/template.test.ts +++ b/tests/condition-trigger/conditions/conditions/template.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; -import { createHASS, createStateEntity } from '../../test-utils'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; +import { createHASS, createStateEntity } from '../../../test-utils'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom @@ -43,4 +43,13 @@ describe('template condition', () => { }).result, ).toBeFalsy(); }); + + it('should accept a template rendering the string "true" for HA symmetry', () => { + const evaluator = createConditionEvaluator( + { condition: 'template' as const, value_template: '{{ "true" }}' }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({ hass: createHASS({}) }).result).toBeTruthy(); + }); }); diff --git a/tests/condition-trigger/conditions/conditions/test-utils.ts b/tests/condition-trigger/conditions/conditions/test-utils.ts new file mode 100644 index 00000000..ff685e08 --- /dev/null +++ b/tests/condition-trigger/conditions/conditions/test-utils.ts @@ -0,0 +1,6 @@ +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { EvaluatorContext } from '../../../../src/condition-trigger/conditions/conditions/types'; + +export const createEvaluatorContext = (): EvaluatorContext => ({ + templateRenderer: new TemplateRenderer(), +}); diff --git a/tests/condition-trigger/conditions/conditions/triggered.test.ts b/tests/condition-trigger/conditions/conditions/triggered.test.ts new file mode 100644 index 00000000..988f8c60 --- /dev/null +++ b/tests/condition-trigger/conditions/conditions/triggered.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('triggered condition', () => { + it('should match any triggered camera when no list is given', () => { + const evaluator = createConditionEvaluator( + { condition: 'triggered' as const }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ triggered: new Set() }).result).toBeFalsy(); + expect(evaluator.evaluate({ triggered: new Set(['camera_1']) }).result).toBeTruthy(); + }); + + it('should match when one of the listed cameras is triggered', () => { + const evaluator = createConditionEvaluator( + { condition: 'triggered' as const, triggered: ['camera_1', 'camera_2'] }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ triggered: new Set(['camera_1']) }).result).toBeTruthy(); + expect( + evaluator.evaluate({ triggered: new Set(['camera_2', 'camera_1', 'camera_3']) }) + .result, + ).toBeTruthy(); + expect(evaluator.evaluate({ triggered: new Set(['camera_3']) }).result).toBeFalsy(); + }); + + it('should match when no camera is triggered for an empty list', () => { + const evaluator = createConditionEvaluator( + { condition: 'triggered' as const, triggered: [] }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeTruthy(); + expect(evaluator.evaluate({ triggered: new Set() }).result).toBeTruthy(); + expect(evaluator.evaluate({ triggered: new Set(['camera_1']) }).result).toBeFalsy(); + }); +}); diff --git a/tests/conditions/conditions/user-agent.test.ts b/tests/condition-trigger/conditions/conditions/user-agent.test.ts similarity index 96% rename from tests/conditions/conditions/user-agent.test.ts rename to tests/condition-trigger/conditions/conditions/user-agent.test.ts index d6b9fbbb..824505ce 100644 --- a/tests/conditions/conditions/user-agent.test.ts +++ b/tests/condition-trigger/conditions/conditions/user-agent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom diff --git a/tests/conditions/conditions/user.test.ts b/tests/condition-trigger/conditions/conditions/user.test.ts similarity index 59% rename from tests/conditions/conditions/user.test.ts rename to tests/condition-trigger/conditions/conditions/user.test.ts index dc7bb248..2e5059f9 100644 --- a/tests/conditions/conditions/user.test.ts +++ b/tests/condition-trigger/conditions/conditions/user.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; -import { createHASS, createUser } from '../../test-utils'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; +import { createHASS, createUser } from '../../../test-utils'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom @@ -20,4 +20,15 @@ describe('user condition', () => { .result, ).toBeFalsy(); }); + + it('should not match when no users are set', () => { + const evaluator = createConditionEvaluator( + { condition: 'user' as const }, + createEvaluatorContext(), + ); + + expect( + evaluator.evaluate({ hass: createHASS({}, createUser({ id: 'user_1' })) }).result, + ).toBeFalsy(); + }); }); diff --git a/tests/conditions/conditions/view.test.ts b/tests/condition-trigger/conditions/conditions/view.test.ts similarity index 53% rename from tests/conditions/conditions/view.test.ts rename to tests/condition-trigger/conditions/conditions/view.test.ts index 7c7128ad..8490b3aa 100644 --- a/tests/conditions/conditions/view.test.ts +++ b/tests/condition-trigger/conditions/conditions/view.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createConditionEvaluator } from '../../../../src/condition-trigger/conditions/factory'; import { createEvaluatorContext } from './test-utils'; // @vitest-environment jsdom @@ -15,29 +15,14 @@ describe('view condition', () => { expect(evaluator.evaluate({ view: 'clips' }).result).toBeFalsy(); }); - it('should report trigger data for any view change', () => { + it('should match any of several named views', () => { const evaluator = createConditionEvaluator( - { condition: 'view' as const }, + { condition: 'view' as const, views: ['live', 'clips'] }, createEvaluatorContext(), ); - expect(evaluator.evaluate({ view: 'clips' }, {})).toEqual({ - result: true, - triggerData: { - view: { - to: 'clips', - }, - }, - }); - - expect(evaluator.evaluate({ view: 'timeline' }, { view: 'clips' })).toEqual({ - result: true, - triggerData: { - view: { - from: 'clips', - to: 'timeline', - }, - }, - }); + expect(evaluator.evaluate({ view: 'live' }).result).toBeTruthy(); + expect(evaluator.evaluate({ view: 'clips' }).result).toBeTruthy(); + expect(evaluator.evaluate({ view: 'timeline' }).result).toBeFalsy(); }); }); diff --git a/tests/condition-trigger/conditions/factory.test.ts b/tests/condition-trigger/conditions/factory.test.ts new file mode 100644 index 00000000..54bf7f8f --- /dev/null +++ b/tests/condition-trigger/conditions/factory.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { CallConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/call'; +import { CameraConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/camera'; +import { DisplayModeConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/display-mode'; +import { ExpandConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/expand'; +import { FullscreenConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/fullscreen'; +import { InteractionConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/interaction'; +import { KeyConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/key'; +import { MediaLoadedConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/media-loaded'; +import { MicrophoneConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/microphone'; +import { TriggeredConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/triggered'; +import { ConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/types'; +import { ViewConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/view'; +import { createConditionEvaluatorForTrigger } from '../../../src/condition-trigger/conditions/factory'; +import { Trigger } from '../../../src/config/schema/condition-trigger/triggers/types'; + +type ConditionEvaluatorConstructor = new (...args: never[]) => ConditionEvaluator; + +describe('createConditionEvaluatorForTrigger', () => { + it.each<[Trigger, ConditionEvaluatorConstructor]>([ + [{ trigger: 'call', call: true }, CallConditionEvaluator], + [{ trigger: 'camera', cameras: ['front'] }, CameraConditionEvaluator], + [{ trigger: 'display_mode', display_mode: 'single' }, DisplayModeConditionEvaluator], + [{ trigger: 'expand', expand: true }, ExpandConditionEvaluator], + [{ trigger: 'fullscreen', fullscreen: true }, FullscreenConditionEvaluator], + [{ trigger: 'interaction', interaction: true }, InteractionConditionEvaluator], + [{ trigger: 'key', key: 'a' }, KeyConditionEvaluator], + [{ trigger: 'media_loaded', media_loaded: true }, MediaLoadedConditionEvaluator], + [{ trigger: 'microphone', muted: true }, MicrophoneConditionEvaluator], + [{ trigger: 'triggered', triggered: ['front'] }, TriggeredConditionEvaluator], + [{ trigger: 'view', views: ['live'] }, ViewConditionEvaluator], + ])( + 'should reuse the matching condition for a valued %o trigger', + (trigger, expected) => { + expect(createConditionEvaluatorForTrigger(trigger)).toBeInstanceOf(expected); + }, + ); + + it.each<[string, Trigger]>([ + ['a valueless trigger fires on any change', { trigger: 'fullscreen' }], + ['config has no matching condition', { trigger: 'config', paths: ['menu.style'] }], + [ + 'stock triggers evaluate themselves', + { trigger: 'state', entity_id: 'binary_sensor.x' }, + ], + ])('should have no condition when %s', (_description, trigger) => { + expect(createConditionEvaluatorForTrigger(trigger)).toBeNull(); + }); +}); diff --git a/tests/conditions/state-manager-via-event.test.ts b/tests/condition-trigger/conditions/state-manager-via-event.test.ts similarity index 86% rename from tests/conditions/state-manager-via-event.test.ts rename to tests/condition-trigger/conditions/state-manager-via-event.test.ts index 2254f907..6de7de55 100644 --- a/tests/conditions/state-manager-via-event.test.ts +++ b/tests/condition-trigger/conditions/state-manager-via-event.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { ConditionStateManager } from '../../src/conditions/state-manager'; +import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager'; import { ConditionStateManagerGetEvent, getConditionStateManagerViaEvent, -} from '../../src/conditions/state-manager-via-event'; +} from '../../../src/condition-trigger/conditions/state-manager-via-event'; // @vitest-environment jsdom describe('getConditionStateManagerViaEvent', () => { diff --git a/tests/conditions/state-manager.test.ts b/tests/condition-trigger/conditions/state-manager.test.ts similarity index 60% rename from tests/conditions/state-manager.test.ts rename to tests/condition-trigger/conditions/state-manager.test.ts index 25050429..70c37643 100644 --- a/tests/conditions/state-manager.test.ts +++ b/tests/condition-trigger/conditions/state-manager.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ConditionStateManager } from '../../src/conditions/state-manager'; -import { createHASS, createStateEntity } from '../test-utils'; +import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager'; +import { + ConditionState, + ConditionStateChange, +} from '../../../src/condition-trigger/conditions/types'; +import { createHASS, createStateEntity } from '../../test-utils'; describe('ConditionStateManager', () => { afterEach(() => { @@ -83,6 +87,46 @@ describe('ConditionStateManager', () => { }); }); + it('should serialize a state change made from within a listener', () => { + const manager = new ConditionStateManager(); + const seen: ConditionStateChange[] = []; + let reentrantReturn: boolean | null | undefined = undefined; + let stateDuringDispatch: ConditionState | undefined; + + manager.addListener((change) => { + seen.push(change); + + // Reentrantly flip the value the originating change just set, capturing + // what is observable mid-dispatch so it can be asserted outside the + // callback (where a failure cannot be swallowed). + if (reentrantReturn === undefined) { + reentrantReturn = manager.setState({ fullscreen: false }); + stateDuringDispatch = manager.getState(); + } + }); + + expect(manager.setState({ fullscreen: true })).toBe(true); + + // The reentrant change is deferred: it returns null and, crucially, is NOT + // applied while the originating dispatch is still in flight -- fullscreen is + // still true despite the reentrant call to set it false. + expect(reentrantReturn).toBeNull(); + expect(stateDuringDispatch).toEqual({ fullscreen: true }); + + // It is dispatched only after the originating change, seeing that change's + // result as its own `old`: a coherent edge, not a torn state. Only now does + // fullscreen become false. + expect(seen).toEqual([ + { old: {}, change: { fullscreen: true }, new: { fullscreen: true } }, + { + old: { fullscreen: true }, + change: { fullscreen: false }, + new: { fullscreen: false }, + }, + ]); + expect(manager.getState()).toEqual({ fullscreen: false }); + }); + it('should add listener', () => { const listener = vi.fn(); const manager = new ConditionStateManager(); diff --git a/tests/condition-trigger/triggers/factory.test.ts b/tests/condition-trigger/triggers/factory.test.ts new file mode 100644 index 00000000..5b7b7bcb --- /dev/null +++ b/tests/condition-trigger/triggers/factory.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { TemplateRenderer } from '../../../src/card-controller/templates'; +import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager'; +import { createTriggerEvaluator } from '../../../src/condition-trigger/triggers/factory'; +import { CallTrigger } from '../../../src/condition-trigger/triggers/triggers/call'; +import { CameraTrigger } from '../../../src/condition-trigger/triggers/triggers/camera'; +import { ConfigTrigger } from '../../../src/condition-trigger/triggers/triggers/config'; +import { DisplayModeTrigger } from '../../../src/condition-trigger/triggers/triggers/display-mode'; +import { ExpandTrigger } from '../../../src/condition-trigger/triggers/triggers/expand'; +import { FullscreenTrigger } from '../../../src/condition-trigger/triggers/triggers/fullscreen'; +import { InitializedTrigger } from '../../../src/condition-trigger/triggers/triggers/initialized'; +import { InteractionTrigger } from '../../../src/condition-trigger/triggers/triggers/interaction'; +import { KeyTrigger } from '../../../src/condition-trigger/triggers/triggers/key'; +import { MediaLoadedTrigger } from '../../../src/condition-trigger/triggers/triggers/media-loaded'; +import { MicrophoneTrigger } from '../../../src/condition-trigger/triggers/triggers/microphone'; +import { NumericStateTrigger } from '../../../src/condition-trigger/triggers/triggers/numeric-state'; +import { ScreenTrigger } from '../../../src/condition-trigger/triggers/triggers/screen'; +import { StateTrigger } from '../../../src/condition-trigger/triggers/triggers/state'; +import { TemplateTrigger } from '../../../src/condition-trigger/triggers/triggers/template'; +import { TriggeredTrigger } from '../../../src/condition-trigger/triggers/triggers/triggered'; +import { + TriggerEvaluator, + TriggerEvaluatorContext, +} from '../../../src/condition-trigger/triggers/triggers/types'; +import { ViewTrigger } from '../../../src/condition-trigger/triggers/triggers/view'; +import { Trigger } from '../../../src/config/schema/condition-trigger/triggers/types'; + +type TriggerEvaluatorConstructor = new (...args: never[]) => TriggerEvaluator; + +// @vitest-environment jsdom +describe('createTriggerEvaluator', () => { + const context = (): TriggerEvaluatorContext => ({ + stateManager: new ConditionStateManager(), + templateRenderer: new TemplateRenderer(), + }); + + it.each<[Trigger, TriggerEvaluatorConstructor]>([ + [{ trigger: 'state', entity_id: 'binary_sensor.x' }, StateTrigger], + [{ trigger: 'numeric_state', entity_id: 'sensor.x', above: 5 }, NumericStateTrigger], + [{ trigger: 'template', value_template: '{{ true }}' }, TemplateTrigger], + [{ trigger: 'call', call: true }, CallTrigger], + [{ trigger: 'camera', cameras: ['front'] }, CameraTrigger], + [{ trigger: 'config' }, ConfigTrigger], + [{ trigger: 'display_mode', display_mode: 'single' }, DisplayModeTrigger], + [{ trigger: 'expand', expand: true }, ExpandTrigger], + [{ trigger: 'fullscreen', fullscreen: true }, FullscreenTrigger], + [{ trigger: 'interaction', interaction: true }, InteractionTrigger], + [{ trigger: 'media_loaded', media_loaded: true }, MediaLoadedTrigger], + [{ trigger: 'microphone', muted: true }, MicrophoneTrigger], + [{ trigger: 'triggered' }, TriggeredTrigger], + [{ trigger: 'view', views: ['live'] }, ViewTrigger], + [{ trigger: 'initialized' }, InitializedTrigger], + [{ trigger: 'key', key: 'a' }, KeyTrigger], + [{ trigger: 'screen' }, ScreenTrigger], + ])('should create the dedicated evaluator for a %o trigger', (trigger, expected) => { + expect(createTriggerEvaluator(trigger, context())).toBeInstanceOf(expected); + }); +}); diff --git a/tests/condition-trigger/triggers/manager.test.ts b/tests/condition-trigger/triggers/manager.test.ts new file mode 100644 index 00000000..c2e7c0e4 --- /dev/null +++ b/tests/condition-trigger/triggers/manager.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it, Mock, vi } from 'vitest'; +import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager'; +import { TriggersManager } from '../../../src/condition-trigger/triggers/manager'; +import { Trigger } from '../../../src/config/schema/condition-trigger/triggers/types'; +import { createHASS, createStateEntity } from '../../test-utils'; + +// @vitest-environment jsdom +describe('TriggersManager', () => { + const create = ( + triggers: Trigger[], + ): { + manager: TriggersManager; + stateManager: ConditionStateManager; + listener: Mock; + } => { + const stateManager = new ConditionStateManager(); + const manager = new TriggersManager(triggers, stateManager); + const listener = vi.fn(); + return { manager, stateManager, listener }; + }; + + it('should notify a listener when any of its trigger evaluators triggers', () => { + const { manager, stateManager, listener } = create([ + { trigger: 'camera', cameras: ['front'] }, + { trigger: 'view', views: ['live'] }, + ]); + manager.addListener(listener); + + stateManager.setState({ camera: 'front' }); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ platform: 'acc', type: 'camera' }), + ); + + stateManager.setState({ view: 'live' }); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ platform: 'acc', type: 'view' }), + ); + }); + + it('should notify every registered listener', () => { + const { manager, stateManager, listener } = create([ + { trigger: 'camera', cameras: ['front'] }, + ]); + const other = vi.fn(); + manager.addListener(listener); + manager.addListener(other); + + stateManager.setState({ camera: 'front' }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(other).toHaveBeenCalledTimes(1); + }); + + it('should register the same listener only once', () => { + const { manager, stateManager, listener } = create([ + { trigger: 'camera', cameras: ['front'] }, + ]); + manager.addListener(listener); + manager.addListener(listener); + + stateManager.setState({ camera: 'front' }); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('should not notify a removed listener', () => { + const { manager, stateManager, listener } = create([ + { trigger: 'camera', cameras: ['front'] }, + ]); + manager.addListener(listener); + manager.removeListener(listener); + + stateManager.setState({ camera: 'front' }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should stop triggering after destroy', () => { + const { manager, stateManager, listener } = create([ + { trigger: 'camera', cameras: ['front'] }, + ]); + manager.addListener(listener); + manager.destroy(); + + stateManager.setState({ camera: 'front' }); + + expect(listener).not.toHaveBeenCalled(); + }); + + describe('enabled', () => { + const ENABLED_TEMPLATE = '{{ is_state("binary_sensor.flag", "on") }}'; + + const createWithFlag = ( + enabled: string, + state: string | null, + ): { stateManager: ConditionStateManager; listener: Mock } => { + const stateManager = new ConditionStateManager(); + if (state !== null) { + stateManager.setState({ + hass: createHASS({ + 'binary_sensor.flag': createStateEntity({ state }), + }), + }); + } + const manager = new TriggersManager( + [{ trigger: 'camera', cameras: ['front'], enabled }], + stateManager, + ); + const listener = vi.fn(); + manager.addListener(listener); + return { stateManager, listener }; + }; + + it('should not trigger a disabled trigger', () => { + const { manager, stateManager, listener } = create([ + { trigger: 'camera', cameras: ['front'], enabled: false }, + ]); + manager.addListener(listener); + + stateManager.setState({ camera: 'front' }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should trigger an explicitly enabled trigger', () => { + const { manager, stateManager, listener } = create([ + { trigger: 'camera', cameras: ['front'], enabled: true }, + ]); + manager.addListener(listener); + + stateManager.setState({ camera: 'front' }); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('should not trigger a trigger whose enabled template does not render true', () => { + const { stateManager, listener } = createWithFlag(ENABLED_TEMPLATE, 'off'); + + stateManager.setState({ camera: 'front' }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should trigger a trigger whose enabled template renders true', () => { + const { stateManager, listener } = createWithFlag(ENABLED_TEMPLATE, 'on'); + + stateManager.setState({ camera: 'front' }); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('should not trigger a trigger whose enabled template cannot render without hass', () => { + const { stateManager, listener } = createWithFlag(ENABLED_TEMPLATE, null); + + stateManager.setState({ camera: 'front' }); + + // With no hass the enabled template cannot render, so the trigger fails + // closed and does not fire. + expect(listener).not.toHaveBeenCalled(); + }); + + it('should re-evaluate the enabled template on each trigger', () => { + const stateManager = new ConditionStateManager(); + stateManager.setState({ + hass: createHASS({ 'binary_sensor.flag': createStateEntity({ state: 'off' }) }), + }); + const manager = new TriggersManager( + [{ trigger: 'camera', cameras: ['front', 'back'], enabled: ENABLED_TEMPLATE }], + stateManager, + ); + const listener = vi.fn(); + manager.addListener(listener); + + // Flag off: the enabled template renders false, so the camera change is + // suppressed and the listener is not notified. + stateManager.setState({ camera: 'front' }); + expect(listener).not.toHaveBeenCalled(); + + // Flag flips on: a subsequent trigger is now allowed through. + stateManager.setState({ + hass: createHASS({ 'binary_sensor.flag': createStateEntity({ state: 'on' }) }), + }); + stateManager.setState({ camera: 'back' }); + expect(listener).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/call.test.ts b/tests/condition-trigger/triggers/triggers/call.test.ts new file mode 100644 index 00000000..82c1cb6b --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/call.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, Mock, vi } from 'vitest'; +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { CallTrigger } from '../../../../src/condition-trigger/triggers/triggers/call'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; + +// @vitest-environment jsdom +describe('CallTrigger', () => { + const create = ( + trigger: TriggerOfType<'call'>, + ): { stateManager: ConditionStateManager; callback: Mock } => { + const stateManager = new ConditionStateManager(); + const callback = vi.fn(); + new CallTrigger(trigger, { + stateManager, + templateRenderer: new TemplateRenderer(), + }).subscribe(callback); + return { stateManager, callback }; + }; + + it('should treat an absent call state as not-in-call', () => { + const { stateManager, callback } = create({ trigger: 'call' }); + + // Absent (undefined) is equivalent to false, so this is not a change. + stateManager.setState({ call: false }); + expect(callback).not.toHaveBeenCalled(); + + stateManager.setState({ call: true }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger only on changes to the given value', () => { + const { stateManager, callback } = create({ trigger: 'call', call: true }); + stateManager.setState({ call: true }); + stateManager.setState({ call: false }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger on the falling edge to a false value', () => { + const { stateManager, callback } = create({ trigger: 'call', call: false }); + + stateManager.setState({ call: true }); + expect(callback).not.toHaveBeenCalled(); + + stateManager.setState({ call: false }); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/camera.test.ts b/tests/condition-trigger/triggers/triggers/camera.test.ts new file mode 100644 index 00000000..8a5e9b3e --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/camera.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, Mock, vi } from 'vitest'; +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { CameraTrigger } from '../../../../src/condition-trigger/triggers/triggers/camera'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createConfig } from '../../../test-utils'; + +// @vitest-environment jsdom +describe('CameraTrigger', () => { + const create = ( + trigger: TriggerOfType<'camera'>, + ): { + cameraTrigger: CameraTrigger; + stateManager: ConditionStateManager; + callback: Mock; + } => { + const stateManager = new ConditionStateManager(); + const callback = vi.fn(); + const cameraTrigger = new CameraTrigger(trigger, { + stateManager, + templateRenderer: new TemplateRenderer(), + }); + return { cameraTrigger, stateManager, callback }; + }; + + it('should trigger when the selected camera changes to a listed one', () => { + const { cameraTrigger, stateManager, callback } = create({ + trigger: 'camera', + cameras: ['front', 'back'], + }); + cameraTrigger.subscribe(callback); + + // An unlisted camera does not trigger. + stateManager.setState({ camera: 'side' }); + expect(callback).not.toHaveBeenCalled(); + + // Crossing to a listed camera triggers, with the before/after trigger data. + stateManager.setState({ camera: 'front' }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + platform: 'acc', + type: 'camera', + from_acc: { camera: 'side' }, + to_acc: { camera: 'front' }, + }); + }); + + it('should trigger again when the camera changes between two listed cameras', () => { + const { cameraTrigger, stateManager, callback } = create({ + trigger: 'camera', + cameras: ['front', 'back'], + }); + cameraTrigger.subscribe(callback); + + stateManager.setState({ camera: 'front' }); + expect(callback).toHaveBeenCalledTimes(1); + + // Still matching, but the camera moved: triggers again. + stateManager.setState({ camera: 'back' }); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith({ + platform: 'acc', + type: 'camera', + from_acc: { camera: 'front' }, + to_acc: { camera: 'back' }, + }); + }); + + it('should not trigger on an unrelated state change while the camera is unchanged', () => { + const { cameraTrigger, stateManager, callback } = create({ + trigger: 'camera', + cameras: ['front'], + }); + cameraTrigger.subscribe(callback); + + stateManager.setState({ camera: 'front' }); + expect(callback).toHaveBeenCalledTimes(1); + + stateManager.setState({ fullscreen: true }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not trigger once the camera leaves the listed set', () => { + const { cameraTrigger, stateManager, callback } = create({ + trigger: 'camera', + cameras: ['front'], + }); + cameraTrigger.subscribe(callback); + + stateManager.setState({ camera: 'front' }); + expect(callback).toHaveBeenCalledTimes(1); + + stateManager.setState({ camera: 'side' }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger on any camera change without a listed set', () => { + const { cameraTrigger, stateManager, callback } = create({ trigger: 'camera' }); + cameraTrigger.subscribe(callback); + + stateManager.setState({ camera: 'side' }); + expect(callback).toHaveBeenCalledTimes(1); + + stateManager.setState({ camera: 'front' }); + expect(callback).toHaveBeenCalledTimes(2); + + // An unrelated change leaves the camera unchanged: no trigger. + stateManager.setState({ fullscreen: true }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should trigger only when the camera becomes unselected for an empty list', () => { + const { cameraTrigger, stateManager, callback } = create({ + trigger: 'camera', + cameras: [], + }); + cameraTrigger.subscribe(callback); + + // Changing between cameras does not trigger. + stateManager.setState({ camera: 'front' }); + stateManager.setState({ camera: 'back' }); + expect(callback).not.toHaveBeenCalled(); + + // Becoming unselected triggers. + stateManager.setState({ camera: undefined }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should include camera, view and config in the trigger data', () => { + const config = createConfig(); + const { cameraTrigger, stateManager, callback } = create({ + trigger: 'camera', + cameras: ['front'], + }); + cameraTrigger.subscribe(callback); + + stateManager.setState({ camera: 'side', view: 'live', config }); + stateManager.setState({ camera: 'front' }); + expect(callback).toHaveBeenLastCalledWith({ + platform: 'acc', + type: 'camera', + from_acc: { camera: 'side', view: 'live', config }, + to_acc: { camera: 'front', view: 'live', config }, + }); + }); + + it('should stop triggering after destroy', () => { + const { cameraTrigger, stateManager, callback } = create({ + trigger: 'camera', + cameras: ['front'], + }); + cameraTrigger.subscribe(callback); + cameraTrigger.destroy(); + + stateManager.setState({ camera: 'front' }); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/config.test.ts b/tests/condition-trigger/triggers/triggers/config.test.ts new file mode 100644 index 00000000..bcf01edd --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/config.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, Mock, vi } from 'vitest'; +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { ConfigTrigger } from '../../../../src/condition-trigger/triggers/triggers/config'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createConfig } from '../../../test-utils'; + +// @vitest-environment jsdom +describe('ConfigTrigger', () => { + const create = ( + trigger: TriggerOfType<'config'>, + ): { + configTrigger: ConfigTrigger; + stateManager: ConditionStateManager; + callback: Mock; + } => { + const stateManager = new ConditionStateManager(); + const callback = vi.fn(); + const configTrigger = new ConfigTrigger(trigger, { + stateManager, + templateRenderer: new TemplateRenderer(), + }); + return { configTrigger, stateManager, callback }; + }; + + it('should trigger whenever the config changes', () => { + const { configTrigger, stateManager, callback } = create({ trigger: 'config' }); + configTrigger.subscribe(callback); + + stateManager.setState({ config: createConfig() }); + expect(callback).toHaveBeenCalledTimes(1); + + stateManager.setState({ config: createConfig({ menu: { style: 'outside' } }) }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should not trigger when the config is unchanged', () => { + const { configTrigger, stateManager, callback } = create({ trigger: 'config' }); + configTrigger.subscribe(callback); + + stateManager.setState({ config: createConfig() }); + expect(callback).toHaveBeenCalledTimes(1); + + // An unrelated state change leaves the config untouched. + stateManager.setState({ fullscreen: true }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger only when a listed path changes', () => { + const { configTrigger, stateManager, callback } = create({ + trigger: 'config', + paths: ['menu.style'], + }); + configTrigger.subscribe(callback); + + stateManager.setState({ + config: createConfig({ menu: { style: 'outside', position: 'top' } }), + }); + expect(callback).toHaveBeenCalledTimes(1); + + // A config change that leaves `menu.style` untouched does not trigger. + stateManager.setState({ + config: createConfig({ menu: { style: 'outside', position: 'bottom' } }), + }); + expect(callback).toHaveBeenCalledTimes(1); + + // Changing `menu.style` triggers. + stateManager.setState({ + config: createConfig({ menu: { style: 'hidden', position: 'bottom' } }), + }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should include camera, view and config in the trigger data', () => { + const config = createConfig(); + const config2 = createConfig({ menu: { style: 'outside' } }); + const { configTrigger, stateManager, callback } = create({ trigger: 'config' }); + configTrigger.subscribe(callback); + + stateManager.setState({ config, camera: 'front', view: 'live' }); + stateManager.setState({ config: config2 }); + expect(callback).toHaveBeenLastCalledWith({ + platform: 'acc', + type: 'config', + from_acc: { camera: 'front', view: 'live', config }, + to_acc: { camera: 'front', view: 'live', config: config2 }, + }); + }); + + it('should stop triggering after destroy', () => { + const { configTrigger, stateManager, callback } = create({ trigger: 'config' }); + configTrigger.subscribe(callback); + configTrigger.destroy(); + + stateManager.setState({ config: createConfig() }); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/display-mode.test.ts b/tests/condition-trigger/triggers/triggers/display-mode.test.ts new file mode 100644 index 00000000..da99e0cb --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/display-mode.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, Mock, vi } from 'vitest'; +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { DisplayModeTrigger } from '../../../../src/condition-trigger/triggers/triggers/display-mode'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; + +// @vitest-environment jsdom +describe('DisplayModeTrigger', () => { + const create = ( + trigger: TriggerOfType<'display_mode'>, + ): { stateManager: ConditionStateManager; callback: Mock } => { + const stateManager = new ConditionStateManager(); + const callback = vi.fn(); + new DisplayModeTrigger(trigger, { + stateManager, + templateRenderer: new TemplateRenderer(), + }).subscribe(callback); + return { stateManager, callback }; + }; + + it('should trigger on any change without a value', () => { + const { stateManager, callback } = create({ trigger: 'display_mode' }); + stateManager.setState({ displayMode: 'single' }); + stateManager.setState({ displayMode: 'grid' }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should trigger only on changes to the given value', () => { + const { stateManager, callback } = create({ + trigger: 'display_mode', + display_mode: 'grid', + }); + stateManager.setState({ displayMode: 'single' }); + expect(callback).not.toHaveBeenCalled(); + + stateManager.setState({ displayMode: 'grid' }); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/expand.test.ts b/tests/condition-trigger/triggers/triggers/expand.test.ts new file mode 100644 index 00000000..ab067737 --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/expand.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, Mock, vi } from 'vitest'; +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { ExpandTrigger } from '../../../../src/condition-trigger/triggers/triggers/expand'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; + +// @vitest-environment jsdom +describe('ExpandTrigger', () => { + const create = ( + trigger: TriggerOfType<'expand'>, + ): { stateManager: ConditionStateManager; callback: Mock } => { + const stateManager = new ConditionStateManager(); + const callback = vi.fn(); + new ExpandTrigger(trigger, { + stateManager, + templateRenderer: new TemplateRenderer(), + }).subscribe(callback); + return { stateManager, callback }; + }; + + it('should trigger on any change without a value', () => { + const { stateManager, callback } = create({ trigger: 'expand' }); + stateManager.setState({ expand: true }); + stateManager.setState({ expand: false }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should trigger only on changes to the given value', () => { + const { stateManager, callback } = create({ trigger: 'expand', expand: true }); + stateManager.setState({ expand: true }); + stateManager.setState({ expand: false }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger on the falling edge to a false value', () => { + const { stateManager, callback } = create({ trigger: 'expand', expand: false }); + + stateManager.setState({ expand: true }); + expect(callback).not.toHaveBeenCalled(); + + stateManager.setState({ expand: false }); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/fullscreen.test.ts b/tests/condition-trigger/triggers/triggers/fullscreen.test.ts new file mode 100644 index 00000000..34c54757 --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/fullscreen.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, Mock, vi } from 'vitest'; +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { FullscreenTrigger } from '../../../../src/condition-trigger/triggers/triggers/fullscreen'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; + +// @vitest-environment jsdom +describe('FullscreenTrigger', () => { + const create = ( + trigger: TriggerOfType<'fullscreen'>, + ): { + fullscreenTrigger: FullscreenTrigger; + stateManager: ConditionStateManager; + callback: Mock; + } => { + const stateManager = new ConditionStateManager(); + const callback = vi.fn(); + const fullscreenTrigger = new FullscreenTrigger(trigger, { + stateManager, + templateRenderer: new TemplateRenderer(), + }); + return { fullscreenTrigger, stateManager, callback }; + }; + + it('should trigger on any change without a value', () => { + const { fullscreenTrigger, stateManager, callback } = create({ + trigger: 'fullscreen', + }); + fullscreenTrigger.subscribe(callback); + + stateManager.setState({ fullscreen: true }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ platform: 'acc', type: 'fullscreen' }); + + // The falling edge triggers too. + stateManager.setState({ fullscreen: false }); + expect(callback).toHaveBeenCalledTimes(2); + + // An unrelated change does not. + stateManager.setState({ expand: true }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should trigger only on changes to the given value', () => { + const { fullscreenTrigger, stateManager, callback } = create({ + trigger: 'fullscreen', + fullscreen: true, + }); + fullscreenTrigger.subscribe(callback); + + stateManager.setState({ fullscreen: true }); + expect(callback).toHaveBeenCalledTimes(1); + + // Leaving fullscreen does not trigger this `true`-filtered trigger. + stateManager.setState({ fullscreen: false }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger on the falling edge to a false value', () => { + const { fullscreenTrigger, stateManager, callback } = create({ + trigger: 'fullscreen', + fullscreen: false, + }); + fullscreenTrigger.subscribe(callback); + + stateManager.setState({ fullscreen: true }); + expect(callback).not.toHaveBeenCalled(); + + stateManager.setState({ fullscreen: false }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should stop triggering after destroy', () => { + const { fullscreenTrigger, stateManager, callback } = create({ + trigger: 'fullscreen', + }); + fullscreenTrigger.subscribe(callback); + fullscreenTrigger.destroy(); + + stateManager.setState({ fullscreen: true }); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/interaction.test.ts b/tests/condition-trigger/triggers/triggers/interaction.test.ts new file mode 100644 index 00000000..2e33fe84 --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/interaction.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, Mock, vi } from 'vitest'; +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { InteractionTrigger } from '../../../../src/condition-trigger/triggers/triggers/interaction'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; + +// @vitest-environment jsdom +describe('InteractionTrigger', () => { + const create = ( + trigger: TriggerOfType<'interaction'>, + ): { stateManager: ConditionStateManager; callback: Mock } => { + const stateManager = new ConditionStateManager(); + const callback = vi.fn(); + new InteractionTrigger(trigger, { + stateManager, + templateRenderer: new TemplateRenderer(), + }).subscribe(callback); + return { stateManager, callback }; + }; + + it('should trigger on any change without a value', () => { + const { stateManager, callback } = create({ trigger: 'interaction' }); + stateManager.setState({ interaction: true }); + stateManager.setState({ interaction: false }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should trigger only on changes to the given value', () => { + const { stateManager, callback } = create({ + trigger: 'interaction', + interaction: true, + }); + stateManager.setState({ interaction: true }); + stateManager.setState({ interaction: false }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger on the falling edge to a false value', () => { + const { stateManager, callback } = create({ + trigger: 'interaction', + interaction: false, + }); + + stateManager.setState({ interaction: true }); + expect(callback).not.toHaveBeenCalled(); + + stateManager.setState({ interaction: false }); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/is-template-true.test.ts b/tests/condition-trigger/triggers/triggers/is-template-true.test.ts new file mode 100644 index 00000000..127774a8 --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/is-template-true.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { isTemplateTrue } from '../../../../src/condition-trigger/triggers/triggers/is-template-true'; + +describe('trigger isTemplateTrue', () => { + it('should pass booleans through', () => { + expect(isTemplateTrue(true)).toBe(true); + expect(isTemplateTrue(false)).toBe(false); + }); + + it('should treat a non-zero number as true', () => { + expect(isTemplateTrue(1)).toBe(true); + expect(isTemplateTrue(0)).toBe(false); + }); + + it('should accept HA truthy strings case-insensitively', () => { + for (const value of ['1', 'true', 'YES', 'On', 'enable']) { + expect(isTemplateTrue(value)).toBe(true); + } + }); + + it('should reject falsey strings and other types', () => { + for (const value of ['0', 'false', 'no', 'off', 'disable', 'whatever']) { + expect(isTemplateTrue(value)).toBe(false); + } + expect(isTemplateTrue(null)).toBe(false); + expect(isTemplateTrue(undefined)).toBe(false); + expect(isTemplateTrue({})).toBe(false); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/media-loaded.test.ts b/tests/condition-trigger/triggers/triggers/media-loaded.test.ts new file mode 100644 index 00000000..a377bc2f --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/media-loaded.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, Mock, vi } from 'vitest'; +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { MediaLoadedTrigger } from '../../../../src/condition-trigger/triggers/triggers/media-loaded'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createMediaLoadedInfo } from '../../../test-utils'; + +// @vitest-environment jsdom +describe('MediaLoadedTrigger', () => { + const create = ( + trigger: TriggerOfType<'media_loaded'>, + ): { stateManager: ConditionStateManager; callback: Mock } => { + const stateManager = new ConditionStateManager(); + const callback = vi.fn(); + new MediaLoadedTrigger(trigger, { + stateManager, + templateRenderer: new TemplateRenderer(), + }).subscribe(callback); + return { stateManager, callback }; + }; + + it('should trigger when media presence changes without a value', () => { + const { stateManager, callback } = create({ trigger: 'media_loaded' }); + + stateManager.setState({ mediaLoadedInfo: createMediaLoadedInfo() }); + expect(callback).toHaveBeenCalledTimes(1); + + stateManager.setState({ mediaLoadedInfo: null }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should trigger only on changes to the given value', () => { + const { stateManager, callback } = create({ + trigger: 'media_loaded', + media_loaded: true, + }); + + stateManager.setState({ mediaLoadedInfo: createMediaLoadedInfo() }); + expect(callback).toHaveBeenCalledTimes(1); + + stateManager.setState({ mediaLoadedInfo: null }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger on the falling edge to a false value', () => { + const { stateManager, callback } = create({ + trigger: 'media_loaded', + media_loaded: false, + }); + + stateManager.setState({ mediaLoadedInfo: createMediaLoadedInfo() }); + expect(callback).not.toHaveBeenCalled(); + + stateManager.setState({ mediaLoadedInfo: null }); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/microphone.test.ts b/tests/condition-trigger/triggers/triggers/microphone.test.ts new file mode 100644 index 00000000..555add54 --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/microphone.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, Mock, vi } from 'vitest'; +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { MicrophoneState } from '../../../../src/card-controller/types'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { MicrophoneTrigger } from '../../../../src/condition-trigger/triggers/triggers/microphone'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; + +// @vitest-environment jsdom +describe('MicrophoneTrigger', () => { + const createMicrophoneState = (state: Partial): MicrophoneState => ({ + connected: false, + muted: false, + forbidden: false, + ...state, + }); + + const create = ( + trigger: TriggerOfType<'microphone'>, + ): { stateManager: ConditionStateManager; callback: Mock } => { + const stateManager = new ConditionStateManager(); + const callback = vi.fn(); + new MicrophoneTrigger(trigger, { + stateManager, + templateRenderer: new TemplateRenderer(), + }).subscribe(callback); + return { stateManager, callback }; + }; + + it('should trigger on any mute change without a value', () => { + const { stateManager, callback } = create({ trigger: 'microphone' }); + stateManager.setState({ microphone: createMicrophoneState({ muted: true }) }); + stateManager.setState({ microphone: createMicrophoneState({ muted: false }) }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should trigger only on changes to the given mute value', () => { + const { stateManager, callback } = create({ trigger: 'microphone', muted: true }); + stateManager.setState({ microphone: createMicrophoneState({ muted: true }) }); + expect(callback).toHaveBeenCalledTimes(1); + + stateManager.setState({ microphone: createMicrophoneState({ muted: false }) }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger on the falling edge to a false value', () => { + const { stateManager, callback } = create({ trigger: 'microphone', muted: false }); + + stateManager.setState({ microphone: createMicrophoneState({ muted: true }) }); + expect(callback).not.toHaveBeenCalled(); + + stateManager.setState({ microphone: createMicrophoneState({ muted: false }) }); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/numeric-state.test.ts b/tests/condition-trigger/triggers/triggers/numeric-state.test.ts new file mode 100644 index 00000000..69da7f2f --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/numeric-state.test.ts @@ -0,0 +1,455 @@ +import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { NumericStateTrigger } from '../../../../src/condition-trigger/triggers/triggers/numeric-state'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createHASS, createStateEntity } from '../../../test-utils'; + +const SENSOR = 'sensor.temperature'; +const SENSOR_TWO = 'sensor.humidity'; +const THRESHOLD = 'input_number.limit'; + +// @vitest-environment jsdom +describe('NumericStateTrigger', () => { + const create = ( + config: TriggerOfType<'numeric_state'>, + ): { + trigger: NumericStateTrigger; + stateManager: ConditionStateManager; + callback: Mock; + } => { + const stateManager = new ConditionStateManager(); + const callback = vi.fn(); + const trigger = new NumericStateTrigger(config, { + stateManager, + templateRenderer: new TemplateRenderer(), + }); + return { trigger, stateManager, callback }; + }; + + const setHass = ( + manager: ConditionStateManager, + states: Record>, + ): void => { + const entities: HassEntities = {}; + for (const [id, partial] of Object.entries(states)) { + entities[id] = createStateEntity(partial); + } + manager.setState({ hass: createHASS(entities) }); + }; + + it('should trigger on the crossing into range', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + }); + trigger.subscribe(callback); + + // Below the threshold: arms but does not trigger. + setHass(stateManager, { [SENSOR]: { state: '10' } }); + expect(callback).not.toHaveBeenCalled(); + + // Crosses above the threshold: triggers once. + setHass(stateManager, { [SENSOR]: { state: '25' } }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + platform: 'numeric_state', + entity_id: SENSOR, + entity: SENSOR, + }), + ); + }); + + it('should not re-trigger while the value stays in range', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + }); + trigger.subscribe(callback); + + setHass(stateManager, { [SENSOR]: { state: '10' } }); + setHass(stateManager, { [SENSOR]: { state: '25' } }); + expect(callback).toHaveBeenCalledTimes(1); + + // Still in range: not a fresh crossing, so no re-trigger. + setHass(stateManager, { [SENSOR]: { state: '26' } }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger again after leaving and re-entering the range', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + }); + trigger.subscribe(callback); + + setHass(stateManager, { [SENSOR]: { state: '10' } }); + setHass(stateManager, { [SENSOR]: { state: '25' } }); + expect(callback).toHaveBeenCalledTimes(1); + + // Leave the range (re-arms), then cross back in. + setHass(stateManager, { [SENSOR]: { state: '10' } }); + setHass(stateManager, { [SENSOR]: { state: '25' } }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should respect a range with both above and below', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + below: 30, + }); + trigger.subscribe(callback); + + // Below the range: arms. + setHass(stateManager, { [SENSOR]: { state: '10' } }); + + // Into the range: triggers. + setHass(stateManager, { [SENSOR]: { state: '25' } }); + expect(callback).toHaveBeenCalledTimes(1); + + // Out the top of the range: re-arms, no trigger. + setHass(stateManager, { [SENSOR]: { state: '35' } }); + expect(callback).toHaveBeenCalledTimes(1); + + // Back into the range: triggers again. + setHass(stateManager, { [SENSOR]: { state: '25' } }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should resolve an entity-id reference as the threshold', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: THRESHOLD, + }); + trigger.subscribe(callback); + + // 10 > 20 is false: arms. + setHass(stateManager, { [SENSOR]: { state: '10' }, [THRESHOLD]: { state: '20' } }); + + // 25 > 20 is true: triggers. + setHass(stateManager, { [SENSOR]: { state: '25' }, [THRESHOLD]: { state: '20' } }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should match against an attribute', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + attribute: 'battery', + above: 20, + }); + trigger.subscribe(callback); + + setHass(stateManager, { [SENSOR]: { state: 'on', attributes: { battery: 10 } } }); + setHass(stateManager, { [SENSOR]: { state: 'on', attributes: { battery: 25 } } }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should match the rendered value_template', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + value_template: `{{ states('${SENSOR}') | float * 10 }}`, + above: 20, + }); + trigger.subscribe(callback); + + // Template value 1 * 10 = 10: arms. + setHass(stateManager, { [SENSOR]: { state: '1' } }); + + // Template value 3 * 10 = 30: crosses in and triggers. + setHass(stateManager, { [SENSOR]: { state: '3' } }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should fan out independently over a list of entities', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: [SENSOR, SENSOR_TWO], + above: 20, + }); + trigger.subscribe(callback); + + setHass(stateManager, { + [SENSOR]: { state: '10' }, + [SENSOR_TWO]: { state: '10' }, + }); + + // Only SENSOR crosses in. + setHass(stateManager, { [SENSOR]: { state: '25' }, [SENSOR_TWO]: { state: '10' } }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenLastCalledWith( + expect.objectContaining({ entity_id: SENSOR }), + ); + + // Now SENSOR_TWO crosses in. + setHass(stateManager, { [SENSOR]: { state: '25' }, [SENSOR_TWO]: { state: '25' } }); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith( + expect.objectContaining({ entity_id: SENSOR_TWO }), + ); + }); + + it('should not trigger for an entity that did not change', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + }); + trigger.subscribe(callback); + + setHass(stateManager, { [SENSOR]: { state: '10' } }); + + // An unrelated entity changes; SENSOR is unchanged so must not trigger. + setHass(stateManager, { + [SENSOR]: { state: '10' }, + [SENSOR_TWO]: { state: '25' }, + }); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should arm an entity already outside the range at subscribe', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + }); + + // Already below the threshold before subscribe: armed at subscribe. + setHass(stateManager, { [SENSOR]: { state: '10' } }); + trigger.subscribe(callback); + + // The first crossing in triggers. + setHass(stateManager, { [SENSOR]: { state: '25' } }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not trigger for an entity already in range at subscribe', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + }); + + // Already in range before subscribe: not armed. + setHass(stateManager, { [SENSOR]: { state: '25' } }); + trigger.subscribe(callback); + + // A further in-range change is not a crossing. + setHass(stateManager, { [SENSOR]: { state: '26' } }); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should not trigger for an entity unreadable at subscribe until it crosses in afresh', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + }); + + // No value at subscribe: not armed (an unreadable entity is not a crossing + // source until it has been outside the range). + trigger.subscribe(callback); + + setHass(stateManager, { [SENSOR]: { state: '25' } }); + expect(callback).not.toHaveBeenCalled(); + + // After it has been outside, the next crossing in triggers. + setHass(stateManager, { [SENSOR]: { state: '10' } }); + setHass(stateManager, { [SENSOR]: { state: '25' } }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should provide the full from_state/to_state objects', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + }); + trigger.subscribe(callback); + + setHass(stateManager, { [SENSOR]: { state: '10' } }); + setHass(stateManager, { [SENSOR]: { state: '25' } }); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + from_state: expect.objectContaining({ state: '10' }), + to_state: expect.objectContaining({ state: '25' }), + }), + ); + }); + + it('should trigger without a from_state when an armed entity is removed and reappears in range', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + }); + trigger.subscribe(callback); + + // Arm the entity below the range, then remove it (still armed). + setHass(stateManager, { [SENSOR]: { state: '10' } }); + setHass(stateManager, {}); + + // It reappears already in range: triggers, but there is no prior state. + setHass(stateManager, { [SENSOR]: { state: '25' } }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenLastCalledWith({ + platform: 'numeric_state', + entity_id: SENSOR, + entity: SENSOR, + to_state: expect.objectContaining({ state: '25' }), + }); + }); + + it('should accept the entity alias', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity: SENSOR, + above: 20, + }); + trigger.subscribe(callback); + + setHass(stateManager, { [SENSOR]: { state: '10' } }); + setHass(stateManager, { [SENSOR]: { state: '25' } }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + describe('for', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('should trigger only after the value is held in range for the duration', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + for: '00:00:05', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [SENSOR]: { state: '10' } }); + setHass(stateManager, { [SENSOR]: { state: '25' } }); + vi.advanceTimersByTime(4999); + expect(callback).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should cancel the pending trigger when the value leaves the range', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + for: '00:00:05', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [SENSOR]: { state: '10' } }); + setHass(stateManager, { [SENSOR]: { state: '25' } }); + vi.advanceTimersByTime(3000); + + // Leaves the range before the hold elapses: cancelled. + setHass(stateManager, { [SENSOR]: { state: '10' } }); + vi.advanceTimersByTime(5000); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should keep the hold across an in-range change', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + for: '00:00:05', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [SENSOR]: { state: '10' } }); + setHass(stateManager, { [SENSOR]: { state: '25' } }); + vi.advanceTimersByTime(3000); + + // Still in range: the fixed-deadline hold keeps running, not reset. + setHass(stateManager, { [SENSOR]: { state: '26' } }); + vi.advanceTimersByTime(2000); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should clear a pending for: timer on destroy', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + for: '00:00:05', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [SENSOR]: { state: '10' } }); + setHass(stateManager, { [SENSOR]: { state: '25' } }); + trigger.destroy(); + vi.advanceTimersByTime(5000); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should reset the for: timer on a fresh crossing', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + for: '00:00:05', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [SENSOR]: { state: '10' } }); + setHass(stateManager, { [SENSOR]: { state: '25' } }); + vi.advanceTimersByTime(3000); + + // Leave and cross back in: the hold restarts from zero. + setHass(stateManager, { [SENSOR]: { state: '10' } }); + setHass(stateManager, { [SENSOR]: { state: '25' } }); + vi.advanceTimersByTime(4000); + expect(callback).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1000); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not trigger when for is unparseable', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + for: 'not-a-duration', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [SENSOR]: { state: '10' } }); + setHass(stateManager, { [SENSOR]: { state: '25' } }); + vi.advanceTimersByTime(100000); + expect(callback).not.toHaveBeenCalled(); + }); + }); + + it('should stop triggering after destroy', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'numeric_state', + entity_id: SENSOR, + above: 20, + }); + trigger.subscribe(callback); + trigger.destroy(); + + setHass(stateManager, { [SENSOR]: { state: '10' } }); + setHass(stateManager, { [SENSOR]: { state: '25' } }); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/screen.test.ts b/tests/condition-trigger/triggers/triggers/screen.test.ts new file mode 100644 index 00000000..11963e91 --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/screen.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, Mock, vi } from 'vitest'; +import { ScreenTrigger } from '../../../../src/condition-trigger/triggers/triggers/screen'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; + +// @vitest-environment jsdom +describe('ScreenTrigger', () => { + let matches = false; + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + + const mockMatchMedia = (): void => { + vi.spyOn(window, 'matchMedia').mockImplementation( + () => + ({ + get matches(): boolean { + return matches; + }, + addEventListener, + removeEventListener, + }) as unknown as MediaQueryList, + ); + }; + + const subscribe = ( + trigger: TriggerOfType<'screen'>, + ): { screenTrigger: ScreenTrigger; callback: Mock } => { + const callback = vi.fn(); + const screenTrigger = new ScreenTrigger(trigger); + screenTrigger.subscribe(callback); + return { screenTrigger, callback }; + }; + + const fireMediaQueryChange = (): void => addEventListener.mock.calls[0][1](); + + afterEach(() => { + vi.restoreAllMocks(); + matches = false; + addEventListener.mockClear(); + removeEventListener.mockClear(); + }); + + it('should trigger on the rising edge of the media query match', () => { + mockMatchMedia(); + const { callback } = subscribe({ + trigger: 'screen', + media_query: '(orientation: landscape)', + }); + + matches = true; + fireMediaQueryChange(); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ platform: 'acc', type: 'screen' }); + }); + + it('should not trigger again while the query stays matching', () => { + mockMatchMedia(); + const { callback } = subscribe({ + trigger: 'screen', + media_query: '(orientation: landscape)', + }); + + matches = true; + fireMediaQueryChange(); + fireMediaQueryChange(); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not trigger on the falling edge of the match', () => { + mockMatchMedia(); + const { callback } = subscribe({ + trigger: 'screen', + media_query: '(orientation: landscape)', + }); + + matches = true; + fireMediaQueryChange(); + matches = false; + fireMediaQueryChange(); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not trigger when already matching at subscribe', () => { + matches = true; + mockMatchMedia(); + const { callback } = subscribe({ + trigger: 'screen', + media_query: '(orientation: landscape)', + }); + + fireMediaQueryChange(); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should stop listening after destroy', () => { + mockMatchMedia(); + const { screenTrigger } = subscribe({ + trigger: 'screen', + media_query: '(orientation: landscape)', + }); + + screenTrigger.destroy(); + expect(removeEventListener).toHaveBeenCalledWith('change', expect.anything()); + }); + + it('should never trigger without a media query', () => { + mockMatchMedia(); + const { callback } = subscribe({ trigger: 'screen' }); + + expect(addEventListener).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/state.test.ts b/tests/condition-trigger/triggers/triggers/state.test.ts new file mode 100644 index 00000000..b49e6660 --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/state.test.ts @@ -0,0 +1,487 @@ +import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { StateTrigger } from '../../../../src/condition-trigger/triggers/triggers/state'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createHASS, createStateEntity } from '../../../test-utils'; + +const ENTITY = 'binary_sensor.door'; +const ENTITY_TWO = 'binary_sensor.window'; + +// @vitest-environment jsdom +describe('StateTrigger', () => { + const create = ( + config: TriggerOfType<'state'>, + ): { + trigger: StateTrigger; + stateManager: ConditionStateManager; + callback: Mock; + } => { + const stateManager = new ConditionStateManager(); + const callback = vi.fn(); + const trigger = new StateTrigger(config, { + stateManager, + templateRenderer: new TemplateRenderer(), + }); + return { trigger, stateManager, callback }; + }; + + const setHass = ( + manager: ConditionStateManager, + states: Record>, + ): void => { + const entities: HassEntities = {}; + for (const [id, partial] of Object.entries(states)) { + entities[id] = createStateEntity(partial); + } + manager.setState({ hass: createHASS(entities) }); + }; + + it('should trigger on a transition to a matching state', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + to: 'on', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + expect(callback).not.toHaveBeenCalled(); + + setHass(stateManager, { [ENTITY]: { state: 'on' } }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'state', entity_id: ENTITY, entity: ENTITY }), + ); + }); + + it('should not trigger on a transition to a non-matching state', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + to: 'on', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + setHass(stateManager, { [ENTITY]: { state: 'unavailable' } }); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should require the from state to match', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + from: 'off', + to: 'on', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [ENTITY]: { state: 'unknown' } }); + setHass(stateManager, { [ENTITY]: { state: 'on' } }); + + // From 'unknown', not 'off'. + expect(callback).not.toHaveBeenCalled(); + + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + setHass(stateManager, { [ENTITY]: { state: 'on' } }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should match not_to', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + not_to: 'unavailable', + }); + trigger.subscribe(callback); + + // Triggers (initial -> off, not unavailable). + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + expect(callback).toHaveBeenCalledTimes(1); + + // To unavailable -> excluded. + setHass(stateManager, { [ENTITY]: { state: 'unavailable' } }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should exclude transitions from a not_from state', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + not_from: 'unavailable', + }); + trigger.subscribe(callback); + + // From undefined (not 'unavailable') -> triggers. + setHass(stateManager, { [ENTITY]: { state: 'unavailable' } }); + expect(callback).toHaveBeenCalledTimes(1); + + // From 'unavailable' -> excluded. + setHass(stateManager, { [ENTITY]: { state: 'on' } }); + expect(callback).toHaveBeenCalledTimes(1); + + // From 'on' -> triggers. + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should match a list of to-states', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + to: ['armed_home', 'armed_away'], + }); + trigger.subscribe(callback); + + setHass(stateManager, { [ENTITY]: { state: 'disarmed' } }); + setHass(stateManager, { [ENTITY]: { state: 'armed_home' } }); + expect(callback).toHaveBeenCalledTimes(1); + setHass(stateManager, { [ENTITY]: { state: 'armed_away' } }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should fan out independently over a list of entities', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: [ENTITY, ENTITY_TWO], + to: 'on', + }); + trigger.subscribe(callback); + + setHass(stateManager, { + [ENTITY]: { state: 'off' }, + [ENTITY_TWO]: { state: 'off' }, + }); + + setHass(stateManager, { [ENTITY]: { state: 'on' }, [ENTITY_TWO]: { state: 'off' } }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenLastCalledWith( + expect.objectContaining({ entity_id: ENTITY }), + ); + + setHass(stateManager, { [ENTITY]: { state: 'on' }, [ENTITY_TWO]: { state: 'on' } }); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith( + expect.objectContaining({ entity_id: ENTITY_TWO }), + ); + }); + + it('should not trigger for a watched entity that did not change', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + to: 'on', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + + // An unrelated entity changes; ENTITY is unchanged so must not trigger. + setHass(stateManager, { + [ENTITY]: { state: 'off' }, + [ENTITY_TWO]: { state: 'on' }, + }); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should provide the full from_state/to_state objects', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + to: 'on', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + setHass(stateManager, { [ENTITY]: { state: 'on' } }); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + from_state: expect.objectContaining({ state: 'off' }), + to_state: expect.objectContaining({ state: 'on' }), + }), + ); + }); + + it('should accept the entity alias', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity: ENTITY, + to: 'on', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + setHass(stateManager, { [ENTITY]: { state: 'on' } }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + describe('attribute-only changes', () => { + it('should trigger on an attribute-only change when no from/to is set', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + }); + trigger.subscribe(callback); + + // Initial change. + setHass(stateManager, { [ENTITY]: { state: 'on', attributes: { a: 1 } } }); + expect(callback).toHaveBeenCalledTimes(1); + + // Attribute-only change still triggers. + setHass(stateManager, { [ENTITY]: { state: 'on', attributes: { a: 2 } } }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should not trigger on an attribute-only change when a to is set', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + to: 'on', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + + // Off -> on. + setHass(stateManager, { [ENTITY]: { state: 'on', attributes: { a: 1 } } }); + expect(callback).toHaveBeenCalledTimes(1); + + // Attribute-only -> suppressed. + setHass(stateManager, { [ENTITY]: { state: 'on', attributes: { a: 2 } } }); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + it('should treat a null to as matching any state change but not attributes', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + to: null, + }); + trigger.subscribe(callback); + + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + expect(callback).toHaveBeenCalledTimes(1); + + // Any state change. + setHass(stateManager, { [ENTITY]: { state: 'on' } }); + expect(callback).toHaveBeenCalledTimes(2); + + // Attribute-only suppressed (key present). + setHass(stateManager, { [ENTITY]: { state: 'on', attributes: { a: 1 } } }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should match against an attribute', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + attribute: 'device_class', + to: 'door', + }); + trigger.subscribe(callback); + + setHass(stateManager, { + [ENTITY]: { state: 'on', attributes: { device_class: 'window' } }, + }); + expect(callback).not.toHaveBeenCalled(); + setHass(stateManager, { + [ENTITY]: { state: 'on', attributes: { device_class: 'door' } }, + }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not trigger when the watched attribute is unchanged', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + attribute: 'device_class', + to: 'door', + }); + trigger.subscribe(callback); + + setHass(stateManager, { + [ENTITY]: { state: 'on', attributes: { device_class: 'window' } }, + }); + + // The state changes but the watched attribute does not -> ignored. + setHass(stateManager, { + [ENTITY]: { state: 'off', attributes: { device_class: 'window' } }, + }); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should treat an absent watched attribute as no value', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + attribute: 'device_class', + to: 'door', + }); + trigger.subscribe(callback); + + // The entity has no `device_class` attribute, so there is no value to match. + setHass(stateManager, { [ENTITY]: { state: 'on' } }); + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should trigger without a to_state when the entity is removed', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + }); + trigger.subscribe(callback); + + setHass(stateManager, { [ENTITY]: { state: 'on' } }); + expect(callback).toHaveBeenCalledTimes(1); + + // Remove the entity from hass entirely. + stateManager.setState({ hass: createHASS({}) }); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith({ + platform: 'state', + entity_id: ENTITY, + entity: ENTITY, + from_state: expect.objectContaining({ state: 'on' }), + }); + }); + + describe('for', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('should trigger only after the state has been held for the duration', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + to: 'on', + for: '00:00:05', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + setHass(stateManager, { [ENTITY]: { state: 'on' } }); + vi.advanceTimersByTime(4999); + expect(callback).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should cancel the pending trigger when the state leaves the match', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + to: 'on', + for: '00:00:05', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + setHass(stateManager, { [ENTITY]: { state: 'on' } }); + vi.advanceTimersByTime(3000); + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + vi.advanceTimersByTime(5000); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should clear a pending for: timer on destroy', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + to: 'on', + for: '00:00:05', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + + // Arms the for: timer. + setHass(stateManager, { [ENTITY]: { state: 'on' } }); + trigger.destroy(); + vi.advanceTimersByTime(5000); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should reset the for: timer on a fresh matching transition', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + to: ['on', 'off'], + for: '00:00:05', + }); + trigger.subscribe(callback); + + // Arms (to includes 'off'). + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + vi.advanceTimersByTime(3000); + + // Re-arms (resets the 5s). + setHass(stateManager, { [ENTITY]: { state: 'on' } }); + vi.advanceTimersByTime(4000); + + // Would have triggered at 5s without the reset. + expect(callback).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1000); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not cancel the for: hold on an attribute-only change', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + to: 'on', + for: '00:00:05', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + + // Arms. + setHass(stateManager, { [ENTITY]: { state: 'on', attributes: { a: 1 } } }); + vi.advanceTimersByTime(3000); + + // The state stays 'on'; only an attribute moves -- the hold must continue. + setHass(stateManager, { [ENTITY]: { state: 'on', attributes: { a: 2 } } }); + + // 5s total since arming. + vi.advanceTimersByTime(2000); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not trigger when for is unparseable', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + to: 'on', + for: 'not-a-duration', + }); + trigger.subscribe(callback); + + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + setHass(stateManager, { [ENTITY]: { state: 'on' } }); + vi.advanceTimersByTime(100000); + expect(callback).not.toHaveBeenCalled(); + }); + }); + + it('should stop triggering after destroy', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'state', + entity_id: ENTITY, + to: 'on', + }); + trigger.subscribe(callback); + trigger.destroy(); + + setHass(stateManager, { [ENTITY]: { state: 'off' } }); + setHass(stateManager, { [ENTITY]: { state: 'on' } }); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/template.test.ts b/tests/condition-trigger/triggers/triggers/template.test.ts new file mode 100644 index 00000000..562f5e9f --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/template.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { TemplateTrigger } from '../../../../src/condition-trigger/triggers/triggers/template'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { createHASS, createStateEntity } from '../../../test-utils'; + +const ENTITY_ONE = 'sensor.foo'; +const ENTITY_TWO = 'sensor.bar'; + +const ENTITY_ONE_ON_TEMPLATE = `{{ is_state("${ENTITY_ONE}", "on") }}`; + +// @vitest-environment jsdom +describe('TemplateTrigger', () => { + const create = ( + config: TriggerOfType<'template'>, + ): { + trigger: TemplateTrigger; + stateManager: ConditionStateManager; + callback: Mock; + } => { + const stateManager = new ConditionStateManager(); + const callback = vi.fn(); + const trigger = new TemplateTrigger(config, { + stateManager, + templateRenderer: new TemplateRenderer(), + }); + return { trigger, stateManager, callback }; + }; + + const setSensor = (manager: ConditionStateManager, state: string): void => { + manager.setState({ + hass: createHASS({ [ENTITY_ONE]: createStateEntity({ state }) }), + }); + }; + + it('should trigger on the rising edge to true', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'template', + value_template: ENTITY_ONE_ON_TEMPLATE, + }); + trigger.subscribe(callback); + + setSensor(stateManager, 'off'); + expect(callback).not.toHaveBeenCalled(); + + setSensor(stateManager, 'on'); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ platform: 'template' }); + }); + + it('should not trigger when the template is already true at subscribe', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'template', + value_template: ENTITY_ONE_ON_TEMPLATE, + }); + setSensor(stateManager, 'on'); + trigger.subscribe(callback); + + // A later change that keeps the template true must not trigger. + stateManager.setState({ + hass: createHASS({ + [ENTITY_ONE]: createStateEntity({ state: 'on' }), + [ENTITY_TWO]: createStateEntity({ state: 'x' }), + }), + }); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should not re-trigger while the template remains true', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'template', + value_template: ENTITY_ONE_ON_TEMPLATE, + }); + trigger.subscribe(callback); + + setSensor(stateManager, 'on'); + expect(callback).toHaveBeenCalledTimes(1); + + stateManager.setState({ + hass: createHASS({ + [ENTITY_ONE]: createStateEntity({ state: 'on' }), + [ENTITY_TWO]: createStateEntity({ state: 'y' }), + }), + }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger again after returning to false then true', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'template', + value_template: ENTITY_ONE_ON_TEMPLATE, + }); + trigger.subscribe(callback); + + setSensor(stateManager, 'on'); + setSensor(stateManager, 'off'); + setSensor(stateManager, 'on'); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should trigger immediately when "for" is zero', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'template', + value_template: ENTITY_ONE_ON_TEMPLATE, + for: 0, + }); + trigger.subscribe(callback); + + setSensor(stateManager, 'on'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + describe('for', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('should trigger only after the template has held true for the duration', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'template', + value_template: ENTITY_ONE_ON_TEMPLATE, + for: '00:00:05', + }); + trigger.subscribe(callback); + + setSensor(stateManager, 'on'); + vi.advanceTimersByTime(4999); + expect(callback).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should cancel the pending trigger when the template goes false before the duration', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'template', + value_template: ENTITY_ONE_ON_TEMPLATE, + for: '00:00:05', + }); + trigger.subscribe(callback); + + setSensor(stateManager, 'on'); + vi.advanceTimersByTime(3000); + setSensor(stateManager, 'off'); + vi.advanceTimersByTime(5000); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should not trigger when "for" is unparseable', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'template', + value_template: ENTITY_ONE_ON_TEMPLATE, + for: 'not-a-duration', + }); + trigger.subscribe(callback); + + setSensor(stateManager, 'on'); + vi.advanceTimersByTime(100000); + expect(callback).not.toHaveBeenCalled(); + }); + }); + + it('should stop triggering after destroy', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'template', + value_template: ENTITY_ONE_ON_TEMPLATE, + }); + trigger.subscribe(callback); + trigger.destroy(); + + setSensor(stateManager, 'on'); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should trigger on a string-truthy template value for HA symmetry', () => { + const { trigger, stateManager, callback } = create({ + trigger: 'template', + + // Renders the string "on"/"off", not a boolean; HA treats "on" as true. + value_template: `{{ "on" if is_state("${ENTITY_ONE}", "on") else "off" }}`, + }); + trigger.subscribe(callback); + + setSensor(stateManager, 'off'); + expect(callback).not.toHaveBeenCalled(); + + setSensor(stateManager, 'on'); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/triggered.test.ts b/tests/condition-trigger/triggers/triggers/triggered.test.ts new file mode 100644 index 00000000..57f57201 --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/triggered.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, Mock, vi } from 'vitest'; +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { TriggeredTrigger } from '../../../../src/condition-trigger/triggers/triggers/triggered'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; + +// @vitest-environment jsdom +describe('TriggeredTrigger', () => { + const create = ( + trigger: TriggerOfType<'triggered'>, + ): { stateManager: ConditionStateManager; callback: Mock } => { + const stateManager = new ConditionStateManager(); + const callback = vi.fn(); + new TriggeredTrigger(trigger, { + stateManager, + templateRenderer: new TemplateRenderer(), + }).subscribe(callback); + return { stateManager, callback }; + }; + + it('should trigger on any change to the triggered set without a value', () => { + const { stateManager, callback } = create({ trigger: 'triggered' }); + stateManager.setState({ triggered: new Set(['front']) }); + stateManager.setState({ triggered: new Set(['front', 'back']) }); + stateManager.setState({ triggered: new Set() }); + expect(callback).toHaveBeenCalledTimes(3); + }); + + it('should trigger only when a listed camera is among the triggered set', () => { + const { stateManager, callback } = create({ + trigger: 'triggered', + triggered: ['front'], + }); + + stateManager.setState({ triggered: new Set(['back']) }); + expect(callback).not.toHaveBeenCalled(); + + stateManager.setState({ triggered: new Set(['front']) }); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/condition-trigger/triggers/triggers/view.test.ts b/tests/condition-trigger/triggers/triggers/view.test.ts new file mode 100644 index 00000000..313e4fe4 --- /dev/null +++ b/tests/condition-trigger/triggers/triggers/view.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, Mock, vi } from 'vitest'; +import { TemplateRenderer } from '../../../../src/card-controller/templates'; +import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager'; +import { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types'; +import { ViewTrigger } from '../../../../src/condition-trigger/triggers/triggers/view'; + +// @vitest-environment jsdom +describe('ViewTrigger', () => { + const create = ( + trigger: TriggerOfType<'view'>, + ): { + viewTrigger: ViewTrigger; + stateManager: ConditionStateManager; + callback: Mock; + } => { + const stateManager = new ConditionStateManager(); + const callback = vi.fn(); + const viewTrigger = new ViewTrigger(trigger, { + stateManager, + templateRenderer: new TemplateRenderer(), + }); + return { viewTrigger, stateManager, callback }; + }; + + it('should trigger when the selected view changes to a listed one', () => { + const { viewTrigger, stateManager, callback } = create({ + trigger: 'view', + views: ['live'], + }); + viewTrigger.subscribe(callback); + + stateManager.setState({ view: 'clip' }); + expect(callback).not.toHaveBeenCalled(); + + stateManager.setState({ view: 'live' }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenLastCalledWith({ + platform: 'acc', + type: 'view', + from_acc: { view: 'clip' }, + to_acc: { view: 'live' }, + }); + }); + + it('should trigger again when the view changes between two listed views', () => { + const { viewTrigger, stateManager, callback } = create({ + trigger: 'view', + views: ['live', 'clips'], + }); + viewTrigger.subscribe(callback); + + stateManager.setState({ view: 'live' }); + expect(callback).toHaveBeenCalledTimes(1); + + stateManager.setState({ view: 'clips' }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should not trigger once the view leaves the listed set', () => { + const { viewTrigger, stateManager, callback } = create({ + trigger: 'view', + views: ['live'], + }); + viewTrigger.subscribe(callback); + + stateManager.setState({ view: 'live' }); + expect(callback).toHaveBeenCalledTimes(1); + + stateManager.setState({ view: 'clips' }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger on any view change without a listed set', () => { + const { viewTrigger, stateManager, callback } = create({ trigger: 'view' }); + viewTrigger.subscribe(callback); + + stateManager.setState({ view: 'clips' }); + expect(callback).toHaveBeenCalledTimes(1); + + stateManager.setState({ view: 'live' }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should stop triggering after destroy', () => { + const { viewTrigger, stateManager, callback } = create({ + trigger: 'view', + views: ['live'], + }); + viewTrigger.subscribe(callback); + viewTrigger.destroy(); + + stateManager.setState({ view: 'live' }); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/conditions/conditions-manager.test.ts b/tests/conditions/conditions-manager.test.ts deleted file mode 100644 index 251e4076..00000000 --- a/tests/conditions/conditions-manager.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ConditionsManager } from '../../src/conditions/conditions-manager'; -import { ConditionStateManager } from '../../src/conditions/state-manager'; -import { createHASS, createStateEntity } from '../test-utils'; - -// Per-condition-type evaluation is covered by tests/conditions/conditions/.test.ts. -// This file covers the manager's own orchestration: building/destroying evaluators, -// listener management, trigger-data merging, and notifying (or not) on changes. - -// @vitest-environment jsdom -describe('ConditionsManager', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should not call listeners for HA state changes without a relevant condition', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - stateManager, - ); - - const listener = vi.fn(); - manager.addListener(listener); - - stateManager.setState({ - hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), - }); - - expect(listener).not.toBeCalled(); - }); - - it('should notify on each change and merge trigger data across multiple conditions', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [ - { condition: 'state' as const, entity: 'switch.one' }, - { condition: 'state' as const, entity: 'switch.two' }, - ], - stateManager, - ); - - const listener = vi.fn(); - manager.addListener(listener); - - stateManager.setState({ - hass: createHASS({ - 'switch.one': createStateEntity({ state: 'on' }), - 'switch.two': createStateEntity({ state: 'off' }), - }), - }); - expect(listener).toHaveBeenLastCalledWith({ - result: true, - triggerData: { - // Only the last matching condition's data is retained. - state: { - entity: 'switch.two', - to: 'off', - }, - }, - }); - - // The result stays true but the trigger data changes, so listeners are - // still notified. - stateManager.setState({ - hass: createHASS({ - 'switch.one': createStateEntity({ state: 'off' }), - 'switch.two': createStateEntity({ state: 'on' }), - }), - }); - expect(listener).toHaveBeenLastCalledWith({ - result: true, - triggerData: { - state: { - entity: 'switch.two', - from: 'off', - to: 'on', - }, - }, - }); - - expect(listener).toBeCalledTimes(2); - }); - - it('should re-evaluate and notify when a subscribed condition source changes', () => { - const addEventListener = vi.fn(); - const removeEventListener = vi.fn(); - vi.spyOn(window, 'matchMedia') - .mockReturnValueOnce({ - addEventListener: addEventListener, - removeEventListener: removeEventListener, - } as unknown as MediaQueryList) - .mockReturnValueOnce({ - matches: false, - } as unknown as MediaQueryList) - .mockReturnValueOnce({ - matches: true, - } as unknown as MediaQueryList); - - const manager = new ConditionsManager([ - { condition: 'screen' as const, media_query: 'whatever' }, - ]); - - const listener = vi.fn(); - manager.addListener(listener); - - // Fire the media-query change; the manager re-evaluates and notifies. - addEventListener.mock.calls[0][1](); - expect(listener).toBeCalledWith({ result: true, triggerData: {} }); - - // Destroy tears the subscription down via the evaluator. - manager.destroy(); - expect(removeEventListener).toBeCalled(); - }); - - describe('should handle listeners correctly', () => { - it('should add listener', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'fullscreen' as const, fullscreen: true }], - stateManager, - ); - - const listener = vi.fn(); - manager.addListener(listener); - - stateManager.setState({ fullscreen: true }); - - expect(listener).toBeCalledWith({ result: true, triggerData: {} }); - expect(listener).toBeCalledTimes(1); - - stateManager.setState({ fullscreen: false }); - expect(listener).toBeCalledWith({ result: false }); - expect(listener).toBeCalledTimes(2); - - // Re-add the same listener (will still only be called once). - manager.addListener(listener); - - stateManager.setState({ fullscreen: true }); - - expect(listener).toBeCalledWith({ result: true, triggerData: {} }); - expect(listener).toBeCalledTimes(3); - }); - - it('should remove listener', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'fullscreen' as const, fullscreen: true }], - stateManager, - ); - - const listener = vi.fn(); - manager.addListener(listener); - manager.removeListener(listener); - - stateManager.setState({ fullscreen: true }); - - expect(listener).not.toBeCalled(); - }); - - it('should remove listener on destroy', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'fullscreen' as const, fullscreen: true }], - stateManager, - ); - - const listener = vi.fn(); - manager.addListener(listener); - manager.destroy(); - - stateManager.setState({ fullscreen: true }); - - expect(listener).not.toBeCalled(); - }); - - it('should not call listeners when the condition result does not change', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'view' as const, views: ['live'] }], - stateManager, - ); - - const listener = vi.fn(); - manager.addListener(listener); - - stateManager.setState({ view: 'live' }); - expect(listener).toBeCalledTimes(1); - - stateManager.setState({ view: 'clip' }); - expect(listener).toBeCalledTimes(2); - - stateManager.setState({ view: 'clip' }); - expect(listener).toBeCalledTimes(2); - - stateManager.setState({ view: 'live' }); - expect(listener).toBeCalledTimes(3); - - stateManager.setState({ view: 'live' }); - expect(listener).toBeCalledTimes(3); - }); - }); -}); diff --git a/tests/conditions/conditions/and.test.ts b/tests/conditions/conditions/and.test.ts deleted file mode 100644 index 7782d693..00000000 --- a/tests/conditions/conditions/and.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; -import { createEvaluatorContext } from './test-utils'; - -// @vitest-environment jsdom -describe('and condition', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should evaluate a simple and condition', () => { - const evaluator = createConditionEvaluator( - { - condition: 'and' as const, - conditions: [ - { condition: 'fullscreen' as const, fullscreen: true }, - { condition: 'expand' as const, expand: true }, - ], - }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({}).result).toBeFalsy(); - expect(evaluator.evaluate({ fullscreen: true }).result).toBeFalsy(); - expect(evaluator.evaluate({ fullscreen: true, expand: true }).result).toBeTruthy(); - expect(evaluator.evaluate({ fullscreen: false, expand: true }).result).toBeFalsy(); - }); - - it('should report combined trigger data for an and condition', () => { - // Not a terribly realistic example, but chosen so that trigger data for - // both camera and view should be returned. - const evaluator = createConditionEvaluator( - { - condition: 'and' as const, - conditions: [{ condition: 'camera' as const }, { condition: 'view' as const }], - }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({}).result).toBeFalsy(); - expect(evaluator.evaluate({ camera: 'camera-1' }, {}).result).toBeFalsy(); - - expect( - evaluator.evaluate( - { camera: 'camera-2', view: 'clip' }, - { camera: 'camera-1', view: 'live' }, - ), - ).toEqual({ - result: true, - triggerData: { - camera: { from: 'camera-1', to: 'camera-2' }, - view: { from: 'live', to: 'clip' }, - }, - }); - }); - - it('should forward subscribe and destroy to its children', () => { - const addEventListener = vi.fn(); - const removeEventListener = vi.fn(); - vi.spyOn(window, 'matchMedia').mockReturnValue({ - addEventListener: addEventListener, - removeEventListener: removeEventListener, - } as unknown as MediaQueryList); - - // The `screen` child supports subscribe/destroy; the `fullscreen` child does - // not — forwarding must handle both. - const evaluator = createConditionEvaluator( - { - condition: 'and' as const, - conditions: [ - { condition: 'screen' as const, media_query: 'whatever' }, - { condition: 'fullscreen' as const, fullscreen: true }, - ], - }, - createEvaluatorContext(), - ); - - const onChange = vi.fn(); - evaluator.subscribe?.(onChange); - expect(addEventListener).toHaveBeenCalledWith('change', expect.anything()); - addEventListener.mock.calls[0][1](); - expect(onChange).toHaveBeenCalled(); - - evaluator.destroy?.(); - expect(removeEventListener).toHaveBeenCalled(); - }); -}); diff --git a/tests/conditions/conditions/camera.test.ts b/tests/conditions/conditions/camera.test.ts deleted file mode 100644 index 5817a1cb..00000000 --- a/tests/conditions/conditions/camera.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; -import { createEvaluatorContext } from './test-utils'; - -// @vitest-environment jsdom -describe('camera condition', () => { - it('should match a named camera', () => { - const evaluator = createConditionEvaluator( - { condition: 'camera' as const, cameras: ['bar'] }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({}).result).toBeFalsy(); - expect(evaluator.evaluate({ camera: 'bar' }).result).toBeTruthy(); - expect(evaluator.evaluate({ camera: 'will-not-match' }).result).toBeFalsy(); - }); - - it('should report trigger data for any camera change', () => { - const evaluator = createConditionEvaluator( - { condition: 'camera' as const }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({ camera: 'bar' }, {})).toEqual({ - result: true, - triggerData: { - camera: { - to: 'bar', - }, - }, - }); - - expect(evaluator.evaluate({ camera: 'foo' }, { camera: 'bar' })).toEqual({ - result: true, - triggerData: { - camera: { - from: 'bar', - to: 'foo', - }, - }, - }); - }); -}); diff --git a/tests/conditions/conditions/config.test.ts b/tests/conditions/conditions/config.test.ts deleted file mode 100644 index 07462648..00000000 --- a/tests/conditions/conditions/config.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; -import { createConfig } from '../../test-utils'; -import { createEvaluatorContext } from './test-utils'; - -// @vitest-environment jsdom -describe('config condition', () => { - const config_1 = createConfig({ - // Default is: - // - // view: { - // default: live, - // }, - }); - const config_2 = createConfig({ - view: { - default: 'clips', - }, - }); - const config_3 = createConfig({ - view: { - default: 'clips', - default_cycle_camera: true, - }, - }); - const config_4 = createConfig({ - view: { - default: 'clips', - default_cycle_camera: true, - dim: true, - }, - }); - - it('should report trigger data for any config change', () => { - const evaluator = createConditionEvaluator( - { condition: 'config' as const }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({ config: config_1 }, {})).toEqual({ - result: true, - triggerData: { - config: { - to: config_1, - }, - }, - }); - - expect(evaluator.evaluate({ config: config_2 }, { config: config_1 })).toEqual({ - result: true, - triggerData: { - config: { - from: config_1, - to: config_2, - }, - }, - }); - }); - - it('should not match when the config is unchanged', () => { - const evaluator = createConditionEvaluator( - { condition: 'config' as const }, - createEvaluatorContext(), - ); - - expect( - evaluator.evaluate({ config: config_1 }, { config: config_1 }).result, - ).toBeFalsy(); - }); - - it('should match a specific config change', () => { - const evaluator = createConditionEvaluator( - { condition: 'config' as const, paths: ['view.default'] }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({ config: config_1 }, {}).result).toBeTruthy(); - expect( - evaluator.evaluate({ config: config_2 }, { config: config_1 }).result, - ).toBeTruthy(); - expect( - evaluator.evaluate({ config: config_3 }, { config: config_2 }).result, - ).toBeFalsy(); - }); - - it('should match multiple specific config changes', () => { - const evaluator = createConditionEvaluator( - { - condition: 'config' as const, - paths: ['view.default', 'view.default_cycle_camera'], - }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({ config: config_1 }, {}).result).toBeTruthy(); - expect( - evaluator.evaluate({ config: config_2 }, { config: config_1 }).result, - ).toBeTruthy(); - expect( - evaluator.evaluate({ config: config_3 }, { config: config_2 }).result, - ).toBeTruthy(); - expect( - evaluator.evaluate({ config: config_4 }, { config: config_3 }).result, - ).toBeFalsy(); - }); -}); diff --git a/tests/conditions/conditions/numeric-state.test.ts b/tests/conditions/conditions/numeric-state.test.ts deleted file mode 100644 index 9206170e..00000000 --- a/tests/conditions/conditions/numeric-state.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; -import { createHASS, createStateEntity } from '../../test-utils'; -import { createEvaluatorContext } from './test-utils'; - -// @vitest-environment jsdom -describe('numeric state condition', () => { - it('should match above a threshold', () => { - const evaluator = createConditionEvaluator( - { condition: 'numeric_state' as const, entity: 'sensor.foo', above: 10 }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({}).result).toBeFalsy(); - expect( - evaluator.evaluate({ - hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), - }).result, - ).toBeTruthy(); - expect( - evaluator.evaluate({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: '9' }) }), - }).result, - ).toBeFalsy(); - }); - - it('should match below a threshold', () => { - const evaluator = createConditionEvaluator( - { condition: 'numeric_state' as const, entity: 'sensor.foo', below: 10 }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({}).result).toBeFalsy(); - expect( - evaluator.evaluate({ - hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), - }).result, - ).toBeFalsy(); - expect( - evaluator.evaluate({ - hass: createHASS({ 'sensor.foo': createStateEntity({ state: '9' }) }), - }).result, - ).toBeTruthy(); - }); -}); diff --git a/tests/conditions/conditions/or.test.ts b/tests/conditions/conditions/or.test.ts deleted file mode 100644 index a04a4318..00000000 --- a/tests/conditions/conditions/or.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; -import { createEvaluatorContext } from './test-utils'; - -// @vitest-environment jsdom -describe('or condition', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should evaluate a simple or condition', () => { - const evaluator = createConditionEvaluator( - { - condition: 'or' as const, - conditions: [ - { condition: 'fullscreen' as const, fullscreen: true }, - { condition: 'expand' as const, expand: true }, - ], - }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({}).result).toBeFalsy(); - expect(evaluator.evaluate({ fullscreen: true }).result).toBeTruthy(); - expect(evaluator.evaluate({ expand: true }).result).toBeTruthy(); - expect(evaluator.evaluate({ fullscreen: false, expand: false }).result).toBeFalsy(); - }); - - it('should report trigger data for the first matching sub-condition', () => { - // Not a terribly realistic example, but chosen so that trigger data for - // both camera and view could be returned. - const evaluator = createConditionEvaluator( - { - condition: 'or' as const, - conditions: [{ condition: 'camera' as const }, { condition: 'view' as const }], - }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({}).result).toBeFalsy(); - - expect(evaluator.evaluate({ camera: 'camera-1' }, {})).toEqual({ - result: true, - triggerData: { camera: { to: 'camera-1' } }, - }); - - expect(evaluator.evaluate({ view: 'live' }, {})).toEqual({ - result: true, - triggerData: { view: { to: 'live' } }, - }); - - // When both change, the camera sub-condition matches first, so only its - // trigger data is returned. - expect( - evaluator.evaluate({ camera: 'camera-2', view: 'clip' }, { camera: 'camera-1' }), - ).toEqual({ - result: true, - triggerData: { camera: { from: 'camera-1', to: 'camera-2' } }, - }); - }); - - it('should forward subscribe and destroy to its children', () => { - const addEventListener = vi.fn(); - const removeEventListener = vi.fn(); - vi.spyOn(window, 'matchMedia').mockReturnValue({ - addEventListener: addEventListener, - removeEventListener: removeEventListener, - } as unknown as MediaQueryList); - - // The `screen` child supports subscribe/destroy; the `fullscreen` child does - // not — forwarding must handle both. - const evaluator = createConditionEvaluator( - { - condition: 'or' as const, - conditions: [ - { condition: 'screen' as const, media_query: 'whatever' }, - { condition: 'fullscreen' as const, fullscreen: true }, - ], - }, - createEvaluatorContext(), - ); - - const onChange = vi.fn(); - evaluator.subscribe?.(onChange); - expect(addEventListener).toHaveBeenCalledWith('change', expect.anything()); - addEventListener.mock.calls[0][1](); - expect(onChange).toHaveBeenCalled(); - - evaluator.destroy?.(); - expect(removeEventListener).toHaveBeenCalled(); - }); -}); diff --git a/tests/conditions/conditions/screen.test.ts b/tests/conditions/conditions/screen.test.ts deleted file mode 100644 index 3578022d..00000000 --- a/tests/conditions/conditions/screen.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; -import { createEvaluatorContext } from './test-utils'; - -// @vitest-environment jsdom -describe('screen condition', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should evaluate the media query', () => { - vi.spyOn(window, 'matchMedia').mockReturnValue({ - matches: true, - } as unknown as MediaQueryList); - - const evaluator = createConditionEvaluator( - { condition: 'screen' as const, media_query: 'whatever' }, - createEvaluatorContext(), - ); - expect(evaluator.evaluate().result).toBeTruthy(); - }); - - it('should register and invoke a media-query listener on subscribe', () => { - const addEventListener = vi.fn(); - vi.spyOn(window, 'matchMedia').mockReturnValue({ - addEventListener: addEventListener, - removeEventListener: vi.fn(), - } as unknown as MediaQueryList); - - const evaluator = createConditionEvaluator( - { condition: 'screen' as const, media_query: 'whatever' }, - createEvaluatorContext(), - ); - - const onChange = vi.fn(); - evaluator.subscribe?.(onChange); - - expect(addEventListener).toHaveBeenCalledWith('change', expect.anything()); - - // Invoke the registered handler and confirm it triggers the callback. - addEventListener.mock.calls[0][1](); - expect(onChange).toHaveBeenCalled(); - }); - - it('should remove the media-query listener on destroy', () => { - const removeEventListener = vi.fn(); - vi.spyOn(window, 'matchMedia').mockReturnValue({ - addEventListener: vi.fn(), - removeEventListener: removeEventListener, - } as unknown as MediaQueryList); - - const evaluator = createConditionEvaluator( - { condition: 'screen' as const, media_query: 'whatever' }, - createEvaluatorContext(), - ); - - evaluator.subscribe?.(vi.fn()); - evaluator.destroy?.(); - expect(removeEventListener).toHaveBeenCalledWith('change', expect.anything()); - }); -}); diff --git a/tests/conditions/conditions/state.test.ts b/tests/conditions/conditions/state.test.ts deleted file mode 100644 index 11e3259e..00000000 --- a/tests/conditions/conditions/state.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; -import { createHASS, createStateEntity } from '../../test-utils'; -import { createEvaluatorContext } from './test-utils'; - -// @vitest-environment jsdom -describe('state condition', () => { - it('should report trigger data for any change when neither state nor state_not is set', () => { - const evaluator = createConditionEvaluator( - { condition: 'state' as const, entity: 'binary_sensor.foo' }, - createEvaluatorContext(), - ); - - expect( - evaluator.evaluate( - { - hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'on' }) }), - }, - {}, - ), - ).toEqual({ - result: true, - triggerData: { - state: { - entity: 'binary_sensor.foo', - to: 'on', - }, - }, - }); - - expect( - evaluator.evaluate( - { - hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), - }, - { - hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'on' }) }), - }, - ), - ).toEqual({ - result: true, - triggerData: { - state: { - entity: 'binary_sensor.foo', - from: 'on', - to: 'off', - }, - }, - }); - }); - - it('should match a single positive state', () => { - const evaluator = createConditionEvaluator( - { condition: 'state' as const, entity: 'binary_sensor.foo', state: 'on' }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({}).result).toBeFalsy(); - expect( - evaluator.evaluate({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), - }).result, - ).toBeTruthy(); - expect( - evaluator.evaluate({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), - }).result, - ).toBeFalsy(); - }); - - it('should match multiple positive states', () => { - const evaluator = createConditionEvaluator( - { - condition: 'state' as const, - entity: 'binary_sensor.foo', - state: ['active', 'on'], - }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({}).result).toBeFalsy(); - expect( - evaluator.evaluate({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), - }).result, - ).toBeTruthy(); - expect( - evaluator.evaluate({ - hass: createHASS({ - 'binary_sensor.foo': createStateEntity({ state: 'active' }), - }), - }).result, - ).toBeTruthy(); - expect( - evaluator.evaluate({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), - }).result, - ).toBeFalsy(); - }); - - it('should match a single negative state', () => { - const evaluator = createConditionEvaluator( - { condition: 'state' as const, entity: 'binary_sensor.foo', state_not: 'on' }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({}).result).toBeFalsy(); - expect( - evaluator.evaluate({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), - }).result, - ).toBeFalsy(); - expect( - evaluator.evaluate({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), - }).result, - ).toBeTruthy(); - }); - - it('should match multiple negative states', () => { - const evaluator = createConditionEvaluator( - { - condition: 'state' as const, - entity: 'binary_sensor.foo', - state_not: ['active', 'on'], - }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({}).result).toBeFalsy(); - expect( - evaluator.evaluate({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), - }).result, - ).toBeFalsy(); - expect( - evaluator.evaluate({ - hass: createHASS({ - 'binary_sensor.foo': createStateEntity({ state: 'active' }), - }), - }).result, - ).toBeFalsy(); - expect( - evaluator.evaluate({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), - }).result, - ).toBeTruthy(); - }); - - it('should match an implicit state condition', () => { - const evaluator = createConditionEvaluator( - { entity: 'binary_sensor.foo', state: 'on' }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({}).result).toBeFalsy(); - expect( - evaluator.evaluate({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), - }).result, - ).toBeTruthy(); - expect( - evaluator.evaluate({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), - }).result, - ).toBeFalsy(); - }); -}); diff --git a/tests/conditions/conditions/test-utils.ts b/tests/conditions/conditions/test-utils.ts deleted file mode 100644 index 6c813514..00000000 --- a/tests/conditions/conditions/test-utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { TemplateRenderer } from '../../../src/card-controller/templates'; -import { EvaluatorContext } from '../../../src/conditions/conditions/types'; - -export const createEvaluatorContext = (): EvaluatorContext => ({ - templateRenderer: new TemplateRenderer(), -}); diff --git a/tests/conditions/conditions/triggered.test.ts b/tests/conditions/conditions/triggered.test.ts deleted file mode 100644 index 1ccf5199..00000000 --- a/tests/conditions/conditions/triggered.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createConditionEvaluator } from '../../../src/conditions/factory'; -import { createEvaluatorContext } from './test-utils'; - -// @vitest-environment jsdom -describe('triggered condition', () => { - it('should match a triggered condition', () => { - const evaluator = createConditionEvaluator( - { condition: 'triggered' as const, triggered: ['camera_1', 'camera_2'] }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({}).result).toBeFalsy(); - expect(evaluator.evaluate({ triggered: new Set(['camera_1']) }).result).toBeTruthy(); - expect( - evaluator.evaluate({ triggered: new Set(['camera_2', 'camera_1', 'camera_3']) }) - .result, - ).toBeTruthy(); - expect(evaluator.evaluate({ triggered: new Set(['camera_3']) }).result).toBeFalsy(); - }); -}); diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index 04b94244..ee57f603 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -7,6 +7,7 @@ import { deleteWithOverrides, getArrayConfigPath, getConfigValue, + hasConfigUpgradeFailures, isConfigUpgradeable, moveConfigValue, setConfigValue, @@ -35,19 +36,19 @@ describe('general functions', () => { }); describe('should get value', () => { - it('present', () => { + it('should get a present value', () => { expect(getConfigValue({ b: 11 }, 'b')).toEqual(11); }); - it('absent', () => { + it('should return undefined for an absent value', () => { expect(getConfigValue({ b: 11 }, 'c')).toBeUndefined(); }); - it('absent with default', () => { + it('should return the default for an absent value', () => { expect(getConfigValue({ b: 11 }, 'c', 12)).toBe(12); }); }); describe('should unset value', () => { - it('nested', () => { + it('should unset a nested value', () => { const target = { moo: { foo: { @@ -62,7 +63,7 @@ describe('general functions', () => { expect(target).toEqual({ moo: { bar: { b: 11 } } }); }); - it('top-level', () => { + it('should unset a top-level value', () => { const target = { a: 10, b: 11, @@ -120,19 +121,47 @@ describe('upgrade functions', () => { ).toBeTruthy(); }); + describe('should detect config upgrade failures', () => { + it('should report no failures for a null config', () => { + expect(hasConfigUpgradeFailures(null)).toBeFalsy(); + }); + + it('should report no failures when nothing has failed', () => { + expect( + hasConfigUpgradeFailures({ type: 'custom:advanced-camera-card' }), + ).toBeFalsy(); + }); + + it('should report no failures when the namespace is not a record', () => { + expect(hasConfigUpgradeFailures({ __UPGRADE_FAILURE__: 'nonsense' })).toBeFalsy(); + }); + + it('should report no failures for an empty namespace', () => { + expect(hasConfigUpgradeFailures({ __UPGRADE_FAILURE__: {} })).toBeFalsy(); + }); + + it('should report a failure when the namespace is non-empty', () => { + expect( + hasConfigUpgradeFailures({ + __UPGRADE_FAILURE__: { automations: [{ actions_not: [] }] }, + }), + ).toBeTruthy(); + }); + }); + describe('should create ranged transform', () => { describe('with numbers', () => { - it('inside range', () => { + it('should keep a value inside the range', () => { expect(createRangedTransform((val) => val, 10, 20)(11)).toBe(11); }); - it('outside range', () => { + it('should clamp a value outside the range', () => { expect(createRangedTransform((val) => val, 10, 20)(1)).toBe(10); }); - it('with a range', () => { + it('should pass a value through with no range bounds', () => { expect(createRangedTransform((val) => val)(100)).toBe(100); }); }); - it('with non-number', () => { + it('should pass a non-number through', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars expect(createRangedTransform((_val) => 'foo')(1)).toBe('foo'); }); @@ -143,7 +172,7 @@ describe('upgrade functions', () => { }); describe('should move config value', () => { - it('simple', () => { + it('should move a value', () => { const config = { foo: { c: 10, @@ -158,7 +187,7 @@ describe('upgrade functions', () => { }); describe('in place', () => { - it('non-transformed', () => { + it('should not modify a non-transformed in-place value', () => { const config = { foo: { c: 10, @@ -172,7 +201,7 @@ describe('upgrade functions', () => { }); }); - it('transformed', () => { + it('should transform an in-place value', () => { const config = { foo: { c: 10, @@ -190,7 +219,7 @@ describe('upgrade functions', () => { }); describe('with transform result', () => { - it('move', () => { + it('should move with a transform result', () => { const config = { c: 10, }; @@ -200,7 +229,7 @@ describe('upgrade functions', () => { expect(config).toEqual({ d: '10' }); }); - it('keep original', () => { + it('should keep the original with a transform result', () => { const config = { c: 10, }; @@ -215,7 +244,7 @@ describe('upgrade functions', () => { }); describe('with transform null result', () => { - it('remove', () => { + it('should remove on a null transform result', () => { const config = { c: 10, }; @@ -226,7 +255,7 @@ describe('upgrade functions', () => { expect(config).toEqual({}); }); - it('keep', () => { + it('should keep the original on a null transform result', () => { const config = { c: 10, }; @@ -241,7 +270,7 @@ describe('upgrade functions', () => { }); }); - it('with transform undefined result', () => { + it('should not modify on an undefined transform result', () => { const config = { c: 10, }; @@ -298,19 +327,19 @@ describe('upgrade functions', () => { }); describe('should upgrade array', () => { - it('in case of non-array', () => { + it('should handle a non-array', () => { const config = { c: 10 }; // eslint-disable-next-line @typescript-eslint/no-unused-vars expect(upgradeArrayOfObjects('c', (_val) => false)(config)).toBeFalsy(); }); - it('in case of non-object items', () => { + it('should handle non-object items', () => { const config = { c: [10, 11] }; // eslint-disable-next-line @typescript-eslint/no-unused-vars expect(upgradeArrayOfObjects('c', (_val) => false)(config)).toBeFalsy(); }); - it('in case of array', () => { + it('should upgrade each object in an array', () => { const config = { c: [{ d: 10 }, { d: 11 }] }; expect( upgradeArrayOfObjects('c', (val) => { @@ -334,14 +363,14 @@ describe('upgrade functions', () => { }); describe('should recursively upgrade', () => { - it('ignoring simple objects', () => { + it('should ignore simple objects', () => { const config = { c: 10, d: 10 }; // eslint-disable-next-line @typescript-eslint/no-unused-vars expect(upgradeObjectRecursively((_val) => false)(config)).toBeFalsy(); expect(config).toEqual({ c: 10, d: 10 }); }); - it('getting sub-objects', () => { + it('should get sub-objects', () => { const config = { c: 10, target_obj: { target_val: 10 } }; expect( upgradeObjectRecursively( @@ -355,7 +384,7 @@ describe('upgrade functions', () => { expect(config).toEqual({ c: 10, target_obj: { target_val: 11 } }); }); - it('iterating into arrays', () => { + it('should iterate into arrays', () => { const config = { values: [{ c: 10 }, { d: 10 }, 'random'] }; expect( upgradeObjectRecursively((val) => { @@ -404,8 +433,8 @@ describe('should handle version specific upgrades', () => { }; describe('v5.2.0 -> v6.0.0', () => { - describe('should rename service_data to data', () => { - it('positive case', () => { + describe('should rename service_data -> data', () => { + it('should rename service_data -> data', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -507,7 +536,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('negative case', () => { + it('should not change a config with nothing to rename', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -528,7 +557,7 @@ describe('should handle version specific upgrades', () => { }); describe('should move PTZ elements to live', () => { - it('case with 1 element', () => { + it('should move a single PTZ element to live', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -584,7 +613,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('case with >1 element', () => { + it('should move a PTZ element to live and keep other elements', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -658,7 +687,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('case with custom conditional element with 2 PTZ but nothing else', () => { + it('should move PTZ from a custom conditional element with nothing else remaining', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -741,7 +770,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('case with custom conditional element with 1 PTZ and another element', () => { + it('should move PTZ from a custom conditional element keeping the other element', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -839,7 +868,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('case with stock conditional element with 1 PTZ', () => { + it('should move PTZ from a stock conditional element', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -901,7 +930,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('case when live.controls.ptz already exists', () => { + it('should not overwrite an existing live.controls.ptz', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -1020,7 +1049,7 @@ describe('should handle version specific upgrades', () => { describe('should handle all and never action conditions', () => { describe('live', () => { describe('lazy_unload', () => { - it('all', () => { + it('should handle all', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1038,7 +1067,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('never', () => { + it('should handle never', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1054,7 +1083,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('other value', () => { + it('should handle another value', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1075,7 +1104,7 @@ describe('should handle version specific upgrades', () => { }); describe('auto_play', () => { - it('all', () => { + it('should handle all', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1091,7 +1120,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('never', () => { + it('should handle never', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1109,7 +1138,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('other value', () => { + it('should handle another value', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1129,7 +1158,7 @@ describe('should handle version specific upgrades', () => { }); }); describe('auto_pause', () => { - it('all', () => { + it('should handle all', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1147,7 +1176,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('never', () => { + it('should handle never', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1163,7 +1192,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('other value', () => { + it('should handle another value', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1183,7 +1212,7 @@ describe('should handle version specific upgrades', () => { }); }); describe('auto_mute', () => { - it('all', () => { + it('should handle all', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1199,7 +1228,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('never', () => { + it('should handle never', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1217,7 +1246,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('other value', () => { + it('should handle another value', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1237,7 +1266,7 @@ describe('should handle version specific upgrades', () => { }); }); describe('auto_unmute', () => { - it('all', () => { + it('should handle all', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1255,7 +1284,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('never', () => { + it('should handle never', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1271,7 +1300,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('other value', () => { + it('should handle another value', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1294,7 +1323,7 @@ describe('should handle version specific upgrades', () => { describe('media_viewer', () => { describe('auto_play', () => { - it('all', () => { + it('should handle all', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1310,7 +1339,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('never', () => { + it('should handle never', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1328,7 +1357,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('other value', () => { + it('should handle another value', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1348,7 +1377,7 @@ describe('should handle version specific upgrades', () => { }); }); describe('auto_pause', () => { - it('all', () => { + it('should handle all', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1364,7 +1393,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('never', () => { + it('should handle never', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1382,7 +1411,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('other value', () => { + it('should handle another value', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1402,7 +1431,7 @@ describe('should handle version specific upgrades', () => { }); }); describe('auto_mute', () => { - it('all', () => { + it('should handle all', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1418,7 +1447,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('never', () => { + it('should handle never', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1436,7 +1465,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('other value', () => { + it('should handle another value', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1456,7 +1485,7 @@ describe('should handle version specific upgrades', () => { }); }); describe('auto_unmute', () => { - it('all', () => { + it('should handle all', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1474,7 +1503,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('never', () => { + it('should handle never', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1490,7 +1519,7 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); - it('other value', () => { + it('should handle another value', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1512,9 +1541,9 @@ describe('should handle version specific upgrades', () => { }); }); - describe('should rename thumbnails.media to thumbnails.events_media_type', () => { + describe('should rename thumbnails.media -> thumbnails.events_media_type', () => { it.each([['all' as const], ['clips' as const], ['snapshots' as const]])( - '%s', + 'should handle %s', (mediaEventType: string) => { const config = { type: 'custom:advanced-camera-card', @@ -1551,9 +1580,9 @@ describe('should handle version specific upgrades', () => { ); }); - describe('should rename timeline.media to timeline.events_media_type', () => { + describe('should rename timeline.media -> timeline.events_media_type', () => { it.each([['all' as const], ['clips' as const], ['snapshots' as const]])( - '%s', + 'should handle %s', (mediaEventType: string) => { const config = { type: 'custom:advanced-camera-card', @@ -1575,9 +1604,9 @@ describe('should handle version specific upgrades', () => { ); }); - describe('should rename live.controls.timeline.media to live.controls.timeline.events_media_type', () => { + describe('should rename live.controls.timeline.media -> live.controls.timeline.events_media_type', () => { it.each([['all' as const], ['clips' as const], ['snapshots' as const]])( - '%s', + 'should handle %s', (mediaEventType: string) => { const config = { type: 'custom:advanced-camera-card', @@ -1607,9 +1636,9 @@ describe('should handle version specific upgrades', () => { ); }); - describe('should rename media_viewer.controls.timeline.media to media_viewer.controls.timeline.events_media_type', () => { + describe('should rename media_viewer.controls.timeline.media -> media_viewer.controls.timeline.events_media_type', () => { it.each([['all' as const], ['clips' as const], ['snapshots' as const]])( - '%s', + 'should handle %s', (mediaEventType: string) => { const config = { type: 'custom:advanced-camera-card', @@ -1641,7 +1670,7 @@ describe('should handle version specific upgrades', () => { describe('should transform scan mode', () => { describe('should move and transform untrigger_reset', () => { - it('when true', () => { + it('should handle when true', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -1666,7 +1695,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('when false', () => { + it('should handle when false', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -1689,7 +1718,7 @@ describe('should handle version specific upgrades', () => { }); describe('should rename view.scan.enabled to a trigger action', () => { - it('when true', () => { + it('should handle when true', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1715,7 +1744,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('when false', () => { + it('should handle when false', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1739,7 +1768,7 @@ describe('should handle version specific upgrades', () => { }); describe('should handle media layout changes', () => { - it('from live.layout to camera_global.dimensions', () => { + it('should move live.layout -> cameras_global.dimensions', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -1775,7 +1804,7 @@ describe('should handle version specific upgrades', () => { describe('from delete old media layouts', () => { it.each([['media_viewer' as const], ['image' as const]])( - '%s', + 'should handle %s', (section: string) => { const config = { type: 'custom:advanced-camera-card', @@ -1804,7 +1833,7 @@ describe('should handle version specific upgrades', () => { describe('from condition object to condition array', () => { describe('with view condition', () => { - it('elements', () => { + it('should convert conditions on elements', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -1844,7 +1873,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('overrides', () => { + it('should convert conditions on overrides', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -1885,7 +1914,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('automations', () => { + it('should convert conditions on automations', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -1910,9 +1939,9 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], automations: [ { - conditions: [ + triggers: [ { - condition: 'view' as const, + trigger: 'view' as const, views: ['clips', 'snapshots'], }, ], @@ -1930,7 +1959,7 @@ describe('should handle version specific upgrades', () => { }); describe('with camera condition', () => { - it('elements', () => { + it('should convert conditions on elements', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -1970,7 +1999,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('overrides', () => { + it('should convert conditions on overrides', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -2011,7 +2040,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('automations', () => { + it('should convert conditions on automations', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -2036,9 +2065,9 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], automations: [ { - conditions: [ + triggers: [ { - condition: 'camera' as const, + trigger: 'camera' as const, cameras: ['camera_1', 'camera_2'], }, ], @@ -2061,7 +2090,7 @@ describe('should handle version specific upgrades', () => { ['expand' as const], ['media_loaded' as const], ])('%s', (condition: string) => { - it('elements', () => { + it('should convert conditions on elements', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -2101,7 +2130,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('overrides', () => { + it('should convert conditions on overrides', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -2142,7 +2171,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('automations', () => { + it('should convert conditions on automations', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -2167,9 +2196,9 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], automations: [ { - conditions: [ + triggers: [ { - condition: condition, + trigger: condition, [condition]: true, }, ], @@ -2188,7 +2217,7 @@ describe('should handle version specific upgrades', () => { }); describe('with state condition', () => { - it('elements', () => { + it('should convert conditions on elements', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -2225,12 +2254,12 @@ describe('should handle version specific upgrades', () => { conditions: [ { condition: 'state' as const, - entity: 'binary_sensor.first', + entity_id: 'binary_sensor.first', state: 'on', }, { condition: 'state' as const, - entity: 'binary_sensor.second', + entity_id: 'binary_sensor.second', state_not: 'off', }, ], @@ -2244,7 +2273,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('overrides', () => { + it('should convert conditions on overrides', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -2281,12 +2310,12 @@ describe('should handle version specific upgrades', () => { conditions: [ { condition: 'state' as const, - entity: 'binary_sensor.first', + entity_id: 'binary_sensor.first', state: 'on', }, { condition: 'state' as const, - entity: 'binary_sensor.second', + entity_id: 'binary_sensor.second', state_not: 'off', }, ], @@ -2301,7 +2330,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('automations', () => { + it('should convert conditions on automations', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -2339,15 +2368,27 @@ describe('should handle version specific upgrades', () => { conditions: [ { condition: 'state' as const, - entity: 'binary_sensor.first', + entity_id: 'binary_sensor.first', state: 'on', }, { condition: 'state' as const, - entity: 'binary_sensor.second', + entity_id: 'binary_sensor.second', state_not: 'off', }, ], + triggers: [ + { + trigger: 'state' as const, + entity_id: 'binary_sensor.first', + to: 'on', + }, + { + trigger: 'state' as const, + entity_id: 'binary_sensor.second', + not_to: 'off', + }, + ], actions: [ { action: 'custom:advanced-camera-card-action' as const, @@ -2362,7 +2403,7 @@ describe('should handle version specific upgrades', () => { }); describe('with media query condition', () => { - it('elements', () => { + it('should convert conditions on elements', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -2402,7 +2443,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('overrides', () => { + it('should convert conditions on overrides', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -2443,7 +2484,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('automations', () => { + it('should convert conditions on automations', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -2468,9 +2509,9 @@ describe('should handle version specific upgrades', () => { cameras: [{ camera_entity: 'camera.office' }], automations: [ { - conditions: [ + triggers: [ { - condition: 'screen' as const, + trigger: 'screen' as const, media_query: 'query', }, ], @@ -2489,7 +2530,7 @@ describe('should handle version specific upgrades', () => { }); describe('from hide to substream capability disable', () => { - it('overrides', () => { + it('should disable substream capability from a hidden camera', () => { const config = { type: 'custom:advanced-camera-card', cameras: [ @@ -2513,7 +2554,7 @@ describe('should handle version specific upgrades', () => { }); describe('from performance profile to generic profile', () => { - it('low performance', () => { + it('should migrate the low performance profile', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -2532,7 +2573,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('high performance', () => { + it('should handle the high performance profile', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -2552,7 +2593,7 @@ describe('should handle version specific upgrades', () => { }); }); - it('from overrides to merge', () => { + it('should move overrides -> merge', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -2617,7 +2658,7 @@ describe('should handle version specific upgrades', () => { tap_action: getActionBefore(action), }); - it('with action_ format', () => { + it('should transform the action_ format', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -2687,7 +2728,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('with data_ format', () => { + it('should transform the data_ format', () => { const getDataAction = (action: PTZControlAction): Actions => ({ action: { device: '048123', @@ -2764,7 +2805,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('with invalid ptz type', () => { + it('should handle an invalid ptz type', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -2778,7 +2819,7 @@ describe('should handle version specific upgrades', () => { expect(upgradeConfig(config)).toBeFalsy(); }); - it('with nothing to transform', () => { + it('should handle nothing to transform', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -2794,7 +2835,7 @@ describe('should handle version specific upgrades', () => { expect(upgradeConfig(config)).toBeFalsy(); }); - it('without tap_action', () => { + it('should handle a missing tap_action', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -2823,7 +2864,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('without pre-existing presets', () => { + it('should handle no pre-existing presets', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -2857,7 +2898,7 @@ describe('should handle version specific upgrades', () => { }); }); - it('view.update_cycle_camera -> view.default_cycle_camera', () => { + it('should rename view.update_cycle_camera -> view.default_cycle_camera', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -2907,7 +2948,7 @@ describe('should handle version specific upgrades', () => { }); }); - it('view.update_seconds', () => { + it('should move view.update_seconds', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -2925,7 +2966,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('view.update_entities', () => { + it('should move view.update_entities', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -2944,7 +2985,7 @@ describe('should handle version specific upgrades', () => { }); describe('title controls to status bar', () => { - it('when mode is none', () => { + it('should handle when mode is none', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -2980,7 +3021,7 @@ describe('should handle version specific upgrades', () => { describe('when mode is invalid type', () => { it.each([[{ mode: { should_not_be: 'an object' } }], ['sideways']])( - '%s', + 'should handle %s', (mode: unknown) => { const config = { type: 'custom:advanced-camera-card', @@ -3018,7 +3059,7 @@ describe('should handle version specific upgrades', () => { it.each([ [`popup-${position}-left` as const], [`popup-${position}-right` as const], - ])('%s', (mode: string) => { + ])('should handle %s', (mode: string) => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -3054,7 +3095,7 @@ describe('should handle version specific upgrades', () => { }); }); - it('rename call-service -> perform-action', () => { + it('should rename call-service -> perform-action', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3109,7 +3150,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('rename dimensions.max_height -> dimensions.height', () => { + it('should rename dimensions.max_height -> dimensions.height', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3128,7 +3169,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('delete dimensions.min_height', () => { + it('should delete dimensions.min_height', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3152,7 +3193,7 @@ describe('should handle version specific upgrades', () => { ['on' as const, true], ['auto' as const, false], ['off' as const, false], - ])('%s', (darkMode: 'on' | 'off' | 'auto', expected: boolean) => { + ])('should handle %s', (darkMode: 'on' | 'off' | 'auto', expected: boolean) => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3174,7 +3215,7 @@ describe('should handle version specific upgrades', () => { }); describe('v7.0.0+', () => { - it('custom:frigate-card -> custom:advanced-camera-card', () => { + it('should rename custom:frigate-card -> custom:advanced-camera-card', () => { const config = { type: 'custom:frigate-card', cameras: [{}], @@ -3187,7 +3228,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('custom:frigate-card-action -> custom:advanced-camera-card-action', () => { + it('should rename custom:frigate-card-action -> custom:advanced-camera-card-action', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3236,7 +3277,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('custom:frigate-card-menu-icon -> custom:advanced-camera-card-menu-icon', () => { + it('should rename custom:frigate-card-menu-icon -> custom:advanced-camera-card-menu-icon', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3261,7 +3302,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('custom:frigate-card-menu-state-icon -> custom:advanced-camera-card-menu-state-icon', () => { + it('should rename custom:frigate-card-menu-state-icon -> custom:advanced-camera-card-menu-state-icon', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3288,7 +3329,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('custom:frigate-card-menu-submenu -> custom:advanced-camera-card-menu-submenu', () => { + it('should rename custom:frigate-card-menu-submenu -> custom:advanced-camera-card-menu-submenu', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3315,7 +3356,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('custom:frigate-card-status-bar-icon -> custom:advanced-camera-card-status-bar-icon', () => { + it('should rename custom:frigate-card-status-bar-icon -> custom:advanced-camera-card-status-bar-icon', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3340,7 +3381,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('custom:frigate-card-status-bar-image -> custom:advanced-camera-card-status-bar-image', () => { + it('should rename custom:frigate-card-status-bar-image -> custom:advanced-camera-card-status-bar-image', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3365,7 +3406,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('custom:frigate-card-status-bar-string -> custom:advanced-camera-card-status-bar-string', () => { + it('should rename custom:frigate-card-status-bar-string -> custom:advanced-camera-card-status-bar-string', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3390,7 +3431,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('custom:frigate-card-conditional -> custom:advanced-camera-card-conditional', () => { + it('should rename custom:frigate-card-conditional -> custom:advanced-camera-card-conditional', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3417,7 +3458,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('frigate_card_action -> advanced_camera_card_action', () => { + it('should rename frigate_card_action -> advanced_camera_card_action', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3467,7 +3508,7 @@ describe('should handle version specific upgrades', () => { }); describe('frigate card style overrides -> advanced camera card style overrides', () => { - it('valid style overrides', () => { + it('should rename valid style overrides', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3534,7 +3575,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('invalid style overrides', () => { + it('should handle invalid style overrides', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3548,7 +3589,7 @@ describe('should handle version specific upgrades', () => { }); }); - it('frigate card button -> iris button', () => { + it('should rename the frigate card button -> the iris button', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3617,7 +3658,7 @@ describe('should handle version specific upgrades', () => { describe('v8.0.0+', () => { describe('live.controls.thumbnails.media_type -> cameras_global.media.type', () => { it.each([['events' as const], ['recordings' as const]])( - '%s', + 'should handle %s', (mediaType: string) => { const config = { type: 'custom:advanced-camera-card', @@ -3652,7 +3693,7 @@ describe('should handle version specific upgrades', () => { describe('live.controls.thumbnails.events_media_type -> cameras_global.media.events_type', () => { it.each([['clips' as const], ['snapshots' as const]])( - '%s', + 'should handle %s', (eventsType: string) => { const config = { type: 'custom:advanced-camera-card', @@ -3684,7 +3725,7 @@ describe('should handle version specific upgrades', () => { }, ); - it('all', () => { + it('should handle all', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -3710,7 +3751,7 @@ describe('should handle version specific upgrades', () => { }); }); - it('view.triggers.untrigger_seconds -> view.triggers.untrigger_delay_seconds', () => { + it('should rename view.triggers.untrigger_seconds -> view.triggers.untrigger_delay_seconds', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -3768,7 +3809,7 @@ describe('should handle version specific upgrades', () => { }); describe('ptz data_*_start/stop -> data_start/end_* (WebRTC ordering)', () => { - it('in cameras_global.ptz', () => { + it('should transform in cameras_global.ptz', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -3790,7 +3831,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('in cameras[n].ptz', () => { + it('should transform in cameras[n].ptz', () => { const config = { type: 'custom:advanced-camera-card', cameras: [ @@ -3813,7 +3854,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('ignores non-object ptz value', () => { + it('should ignore a non-object ptz value', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -3825,7 +3866,7 @@ describe('should handle version specific upgrades', () => { expect(upgradeConfig(config)).toBeFalsy(); }); - it('does not overwrite existing WebRTC key', () => { + it('should not overwrite an existing WebRTC key', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{}], @@ -3847,8 +3888,8 @@ describe('should handle version specific upgrades', () => { }); }); - describe('microphone.connected → call condition', () => { - it('rewrites connected:true to call:true in an automation', () => { + describe('microphone.connected -> call condition', () => { + it('should rewrite connected:true -> call:true in an automation', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3865,13 +3906,13 @@ describe('should handle version specific upgrades', () => { ], }; expect(upgradeConfig(config)).toBeTruthy(); - expect(config.automations[0].conditions).toEqual([ - { condition: 'call', call: true }, - ]); + expect(config.automations[0]).toEqual( + expect.objectContaining({ triggers: [{ trigger: 'call', call: true }] }), + ); postUpgradeChecks(config); }); - it('rewrites connected:false to call:false', () => { + it('should rewrite connected:false -> call:false', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3888,13 +3929,13 @@ describe('should handle version specific upgrades', () => { ], }; expect(upgradeConfig(config)).toBeTruthy(); - expect(config.automations[0].conditions).toEqual([ - { condition: 'call', call: false }, - ]); + expect(config.automations[0]).toEqual( + expect.objectContaining({ triggers: [{ trigger: 'call', call: false }] }), + ); postUpgradeChecks(config); }); - it('leaves a microphone.muted only condition untouched', () => { + it('should not convert a microphone.muted only condition to call', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3910,14 +3951,16 @@ describe('should handle version specific upgrades', () => { }, ], }; - expect(upgradeConfig(config)).toBeFalsy(); - expect(config.automations[0].conditions).toEqual([ - { condition: 'microphone', muted: true }, - ]); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual( + expect.objectContaining({ + triggers: [{ trigger: 'microphone', muted: true }], + }), + ); postUpgradeChecks(config); }); - it('splits a condition with both connected and muted into an AND condition', () => { + it('should split a condition with both connected and muted into an AND condition', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3946,7 +3989,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('migrates a microphone.connected nested under or/and/not', () => { + it('should migrate a microphone.connected nested under or/and/not', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -3997,7 +4040,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('migrates conditions on elements and overrides', () => { + it('should migrate conditions on elements and overrides', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -4025,7 +4068,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('is idempotent', () => { + it('should be idempotent', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -4045,15 +4088,1121 @@ describe('should handle version specific upgrades', () => { // Running upgradeConfig again should not change anything. expect(upgradeConfig(config)).toBeFalsy(); - expect(config.automations[0].conditions).toEqual([ - { condition: 'call', call: true }, - ]); + expect(config.automations[0]).toEqual( + expect.objectContaining({ triggers: [{ trigger: 'call', call: true }] }), + ); postUpgradeChecks(config); }); }); - describe('cameras[].triggers.events string[] → triggers.media_events', () => { - it('migrates a legacy string array to media_events', () => { + describe('automation conditions -> triggers', () => { + it('should flatten a composite condition into trigger leaves and keep the composite', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [ + { + condition: 'or', + conditions: [ + { condition: 'camera', cameras: ['front'] }, + { condition: 'view', views: ['live'] }, + ], + }, + ], + actions: [ + { action: 'fire-dom-event', advanced_camera_card_action: 'live' }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + conditions: [ + { + condition: 'or', + conditions: [ + { condition: 'camera', cameras: ['front'] }, + { condition: 'view', views: ['live'] }, + ], + }, + ], + triggers: [ + { trigger: 'camera', cameras: ['front'] }, + { trigger: 'view', views: ['live'] }, + ], + actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }], + }); + postUpgradeChecks(config); + }); + + it('should leave non-object conditions untouched while rewriting the rest', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [ + 'not-a-condition', + { condition: 'camera', cameras: ['front'] }, + ], + actions: [ + { action: 'fire-dom-event', advanced_camera_card_action: 'live' }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + conditions: ['not-a-condition', { condition: 'camera', cameras: ['front'] }], + triggers: ['not-a-condition', { trigger: 'camera', cameras: ['front'] }], + actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }], + }); + }); + + it('should leave an automation with no conditions untouched', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + actions: [ + { action: 'fire-dom-event', advanced_camera_card_action: 'live' }, + ], + }, + ], + }; + upgradeConfig(config); + expect(config.automations[0]).toEqual({ + actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }], + }); + }); + + it('should map a discriminator-less state condition to a state trigger', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [{ entity: 'binary_sensor.x', state: 'on' }], + actions: [ + { action: 'fire-dom-event', advanced_camera_card_action: 'live' }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + triggers: [{ trigger: 'state', entity_id: 'binary_sensor.x', to: 'on' }], + actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }], + }); + postUpgradeChecks(config); + }); + + it('should drop a composite with no nested conditions from the trigger list', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [ + { condition: 'or' }, + { condition: 'camera', cameras: ['front'] }, + ], + actions: [ + { action: 'fire-dom-event', advanced_camera_card_action: 'live' }, + ], + }, + ], + }; + upgradeConfig(config); + expect(config.automations[0]).toEqual({ + conditions: [{ condition: 'or' }, { condition: 'camera', cameras: ['front'] }], + triggers: [{ trigger: 'camera', cameras: ['front'] }], + actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }], + }); + }); + + it('should promote stock numeric_state and template by swapping the discriminator', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [ + { condition: 'numeric_state', entity_id: 'sensor.t', above: 25 }, + { condition: 'template', value_template: '{{ true }}' }, + ], + actions: [ + { action: 'fire-dom-event', advanced_camera_card_action: 'live' }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + conditions: [ + { condition: 'numeric_state', entity_id: 'sensor.t', above: 25 }, + { condition: 'template', value_template: '{{ true }}' }, + ], + triggers: [ + { trigger: 'numeric_state', entity_id: 'sensor.t', above: 25 }, + { trigger: 'template', value_template: '{{ true }}' }, + ], + actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }], + }); + postUpgradeChecks(config); + }); + + it('should drop a trigger-only condition from the retained conditions', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [ + { condition: 'camera' }, + { condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }, + ], + actions: [ + { action: 'fire-dom-event', advanced_camera_card_action: 'live' }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }, + ], + triggers: [ + { trigger: 'camera' }, + { trigger: 'state', entity_id: 'binary_sensor.door', to: 'on' }, + ], + actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }], + }); + postUpgradeChecks(config); + }); + + it('should drop the conditions block when all are trigger-only', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [{ condition: 'camera' }, { condition: 'view' }], + actions: [ + { action: 'fire-dom-event', advanced_camera_card_action: 'live' }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + triggers: [{ trigger: 'camera' }, { trigger: 'view' }], + actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }], + }); + postUpgradeChecks(config); + }); + + it('should prune trigger-only leaves from a retained composite', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [ + { + condition: 'or', + conditions: [ + { condition: 'camera' }, + { condition: 'view', views: ['live'] }, + ], + }, + ], + actions: [ + { action: 'fire-dom-event', advanced_camera_card_action: 'live' }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + conditions: [ + { condition: 'or', conditions: [{ condition: 'view', views: ['live'] }] }, + ], + triggers: [{ trigger: 'camera' }, { trigger: 'view', views: ['live'] }], + actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }], + }); + postUpgradeChecks(config); + }); + + it('should drop a composite whose leaves are all trigger-only', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [ + { condition: 'or', conditions: [{ condition: 'camera' }] }, + { condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }, + ], + actions: [ + { action: 'fire-dom-event', advanced_camera_card_action: 'live' }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }, + ], + triggers: [ + { trigger: 'camera' }, + { trigger: 'state', entity_id: 'binary_sensor.door', to: 'on' }, + ], + actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }], + }); + postUpgradeChecks(config); + }); + + it('should promote a config condition to a config trigger', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [ + { condition: 'config', paths: ['menu.style'] }, + { condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }, + ], + actions: [ + { action: 'fire-dom-event', advanced_camera_card_action: 'live' }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }, + ], + triggers: [ + { trigger: 'config', paths: ['menu.style'] }, + { trigger: 'state', entity_id: 'binary_sensor.door', to: 'on' }, + ], + actions: [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }], + }); + postUpgradeChecks(config); + }); + }); + + describe('automation actions_not -> if/then/else', () => { + const automate = ( + ...automations: RawAdvancedCameraCardConfig[] + ): RawAdvancedCameraCardConfig & { + automations: RawAdvancedCameraCardConfig[]; + __UPGRADE_FAILURE__?: RawAdvancedCameraCardConfig; + } => ({ + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations, + }); + + const then = [{ action: 'fire-dom-event', advanced_camera_card_action: 'live' }]; + const elseActions = [ + { action: 'fire-dom-event', advanced_camera_card_action: 'clips' }, + ]; + + it('should migrate a self-gating state toggle to an if/then/else action', () => { + const config = automate({ + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }, + ], + actions: then, + actions_not: elseActions, + }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + triggers: [{ trigger: 'state', entity_id: 'binary_sensor.x' }], + actions: [ + { + if: [{ condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }], + then, + else: elseActions, + }, + ], + }); + postUpgradeChecks(config); + }); + + it('should default then to an empty list when there are no then actions', () => { + const config = automate({ + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }, + ], + actions_not: elseActions, + }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + triggers: [{ trigger: 'state', entity_id: 'binary_sensor.x' }], + actions: [ + { + if: [{ condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }], + then: [], + else: elseActions, + }, + ], + }); + postUpgradeChecks(config); + }); + + it('should synthesize a valueless trigger for a card-state condition', () => { + const config = automate({ + conditions: [{ condition: 'fullscreen', fullscreen: true }], + actions: then, + actions_not: elseActions, + }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + triggers: [{ trigger: 'fullscreen' }], + actions: [ + { + if: [{ condition: 'fullscreen', fullscreen: true }], + then, + else: elseActions, + }, + ], + }); + postUpgradeChecks(config); + }); + + it('should watch the entity for a numeric_state condition', () => { + const config = automate({ + conditions: [{ condition: 'numeric_state', entity_id: 'sensor.t', above: 25 }], + actions: then, + actions_not: elseActions, + }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]['triggers']).toEqual([ + { trigger: 'state', entity_id: 'sensor.t' }, + ]); + postUpgradeChecks(config); + }); + + it('should preserve config paths in a synthesized config trigger', () => { + const config = automate({ + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }, + // `config` is trigger-only: it is dropped from the `if` predicate but + // still contributes a trigger (with its `paths` preserved). + { condition: 'config', paths: ['menu.style'] }, + ], + actions: then, + actions_not: elseActions, + }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]['triggers']).toEqual([ + { trigger: 'state', entity_id: 'binary_sensor.x' }, + { trigger: 'config', paths: ['menu.style'] }, + ]); + expect(config.automations[0]['actions']).toEqual([ + { + if: [{ condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }], + then, + else: elseActions, + }, + ]); + postUpgradeChecks(config); + }); + + it('should deduplicate identical synthesized triggers', () => { + const config = automate({ + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }, + { condition: 'state', entity_id: 'binary_sensor.x', state_not: 'off' }, + ], + actions: then, + actions_not: elseActions, + }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]['triggers']).toEqual([ + { trigger: 'state', entity_id: 'binary_sensor.x' }, + ]); + postUpgradeChecks(config); + }); + + it('should record a template condition as an upgrade failure, untouched', () => { + const failing = { + conditions: [{ condition: 'template', value_template: '{{ true }}' }], + actions: then, + actions_not: elseActions, + }; + const config = automate({ ...failing }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations).toEqual([]); + expect(config['__UPGRADE_FAILURE__']).toEqual({ automations: [failing] }); + postUpgradeChecks(config); + }); + + it('should record a screen condition as an upgrade failure, untouched', () => { + const failing = { + conditions: [{ condition: 'screen', media_query: '(orientation: landscape)' }], + actions: then, + actions_not: elseActions, + }; + const config = automate({ ...failing }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations).toEqual([]); + expect(config['__UPGRADE_FAILURE__']).toEqual({ automations: [failing] }); + postUpgradeChecks(config); + }); + + it('should record a numeric_state condition with no entity to watch as an upgrade failure', () => { + const failing = { + conditions: [ + { condition: 'numeric_state', above: 25, value_template: '{{ 5 }}' }, + ], + actions: then, + actions_not: elseActions, + }; + const config = automate({ ...failing }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations).toEqual([]); + expect(config['__UPGRADE_FAILURE__']).toEqual({ automations: [failing] }); + postUpgradeChecks(config); + }); + + it('should record mixed conditions as an upgrade failure when any leaf is rising-edge-only', () => { + const failing = { + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }, + { condition: 'template', value_template: '{{ true }}' }, + ], + actions: then, + actions_not: elseActions, + }; + const config = automate({ ...failing }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations).toEqual([]); + expect(config['__UPGRADE_FAILURE__']).toEqual({ automations: [failing] }); + postUpgradeChecks(config); + }); + + it('should convert one automation and fail another in the same config', () => { + const failing = { + conditions: [{ condition: 'screen', media_query: '(orientation: landscape)' }], + actions: then, + actions_not: elseActions, + }; + const config = automate( + { + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }, + ], + actions: then, + actions_not: elseActions, + }, + { ...failing }, + ); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations).toEqual([ + { + triggers: [{ trigger: 'state', entity_id: 'binary_sensor.x' }], + actions: [ + { + if: [{ condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }], + then, + else: elseActions, + }, + ], + }, + ]); + expect(config['__UPGRADE_FAILURE__']).toEqual({ automations: [failing] }); + postUpgradeChecks(config); + }); + + it('should append to existing automation failures and leave others untouched', () => { + const earlier = { some: 'earlier failed automation' }; + const failing = { + conditions: [{ condition: 'screen', media_query: '(orientation: landscape)' }], + actions: then, + actions_not: elseActions, + }; + const config = automate({ ...failing }); + config['__UPGRADE_FAILURE__'] = { + automations: [earlier], + overrides: ['untouched'], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config['__UPGRADE_FAILURE__']).toEqual({ + automations: [earlier, failing], + overrides: ['untouched'], + }); + postUpgradeChecks(config); + }); + + it('should fall back to an initialized trigger for all-static conditions', () => { + const config = automate({ + conditions: [{ condition: 'user', users: ['abc'] }], + actions: then, + actions_not: elseActions, + }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + triggers: [{ trigger: 'initialized' }], + actions: [ + { + if: [{ condition: 'user', users: ['abc'] }], + then, + else: elseActions, + }, + ], + }); + postUpgradeChecks(config); + }); + + it('should not synthesize a trigger for a user condition that cannot change at runtime', () => { + const config = automate({ + conditions: [ + { condition: 'user', users: ['abc'] }, + { condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }, + ], + actions: then, + actions_not: elseActions, + }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]['triggers']).toEqual([ + { trigger: 'state', entity_id: 'binary_sensor.x' }, + ]); + postUpgradeChecks(config); + }); + + it('should drop actions_not when there are no conditions', () => { + const config = automate({ + triggers: [{ trigger: 'state', entity_id: 'binary_sensor.x' }], + actions: then, + actions_not: elseActions, + }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + triggers: [{ trigger: 'state', entity_id: 'binary_sensor.x' }], + actions: then, + }); + postUpgradeChecks(config); + }); + + it('should keep only the then actions when the conditions are entirely trigger-only', () => { + const config = automate({ + conditions: [{ condition: 'config', paths: ['menu.style'] }], + actions: then, + actions_not: elseActions, + }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + triggers: [{ trigger: 'config', paths: ['menu.style'] }], + actions: then, + }); + postUpgradeChecks(config); + }); + + it('should synthesize a valueless config trigger when no paths are given', () => { + const config = automate({ + conditions: [{ condition: 'config' }], + actions: then, + actions_not: elseActions, + }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + triggers: [{ trigger: 'config' }], + actions: then, + }); + postUpgradeChecks(config); + }); + + it('should map a discriminator-less state condition to a state trigger', () => { + const config = automate({ + conditions: [{ entity: 'binary_sensor.x', state: 'on' }], + actions: then, + actions_not: elseActions, + }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + triggers: [{ trigger: 'state', entity_id: 'binary_sensor.x' }], + actions: [ + { + if: [{ entity: 'binary_sensor.x', state: 'on' }], + then, + else: elseActions, + }, + ], + }); + postUpgradeChecks(config); + }); + + it('should omit else when actions_not is not a list', () => { + const config = automate({ + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }, + ], + actions: then, + actions_not: 'not-a-list', + }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toEqual({ + triggers: [{ trigger: 'state', entity_id: 'binary_sensor.x' }], + actions: [ + { + if: [{ condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }], + then, + }, + ], + }); + postUpgradeChecks(config); + }); + + it('should contribute no trigger for a non-object condition leaf', () => { + const config = automate({ + conditions: [ + 'not-a-condition', + { condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }, + ], + actions: then, + actions_not: elseActions, + }); + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]['triggers']).toEqual([ + { trigger: 'state', entity_id: 'binary_sensor.x' }, + ]); + }); + + it('should leave a non-object automation entry untouched', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + 'not-an-automation', + { + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }, + ], + actions: then, + actions_not: elseActions, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0]).toBe('not-an-automation'); + expect(config.automations[1]).toEqual({ + triggers: [{ trigger: 'state', entity_id: 'binary_sensor.x' }], + actions: [ + { + if: [{ condition: 'state', entity_id: 'binary_sensor.x', state: 'on' }], + then, + else: elseActions, + }, + ], + }); + }); + }); + + describe('trigger-only conditions in overrides and elements', () => { + it('should drop an override whose only condition is a config condition', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: [{ condition: 'config', paths: ['menu.style'] }], + merge: { menu: { style: 'none' } }, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.overrides).toEqual([]); + postUpgradeChecks(config); + }); + + it('should strip a config condition from an override with other conditions', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: [ + { condition: 'config', paths: ['menu.style'] }, + { condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }, + ], + merge: { menu: { style: 'none' } }, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.overrides).toEqual([ + { + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }, + ], + merge: { menu: { style: 'none' } }, + }, + ]); + postUpgradeChecks(config); + }); + + it('should drop an override whose only condition is a valueless camera condition', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: [{ condition: 'camera' }], + merge: { menu: { style: 'none' } }, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.overrides).toEqual([]); + postUpgradeChecks(config); + }); + + it('should drop a conditional element whose only condition is a config condition', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'custom:advanced-camera-card-conditional', + conditions: [{ condition: 'config', paths: ['menu.style'] }], + elements: [{ type: 'icon', icon: 'mdi:cow' }], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.elements).toBeUndefined(); + postUpgradeChecks(config); + }); + + it('should strip a config condition from a conditional element with other conditions', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'custom:advanced-camera-card-conditional', + conditions: [ + { condition: 'config', paths: ['menu.style'] }, + { condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }, + ], + elements: [{ type: 'icon', icon: 'mdi:cow' }], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.elements).toEqual([ + { + type: 'custom:advanced-camera-card-conditional', + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }, + ], + elements: [{ type: 'icon', icon: 'mdi:cow' }], + }, + ]); + postUpgradeChecks(config); + }); + + it('should recurse into nested conditional elements, keeping siblings', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'custom:advanced-camera-card-conditional', + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }, + ], + elements: [ + { type: 'icon', icon: 'mdi:cow' }, + { + type: 'custom:advanced-camera-card-conditional', + conditions: [{ condition: 'config', paths: ['menu.style'] }], + elements: [{ type: 'icon', icon: 'mdi:pig' }], + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.elements).toEqual([ + { + type: 'custom:advanced-camera-card-conditional', + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }, + ], + elements: [{ type: 'icon', icon: 'mdi:cow' }], + }, + ]); + postUpgradeChecks(config); + }); + + it('should leave malformed entries (no conditions / no elements) untouched', () => { + // These shapes are schema-invalid, so the transform must not crash on + // them (a non-object override, an entry without a `conditions` array, a + // conditional without an `elements` array); it simply leaves them as-is. + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: ['malformed', { merge: {} }], + elements: [ + { + type: 'custom:advanced-camera-card-conditional', + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }, + ], + }, + ], + }; + upgradeConfig(config); + expect(config.overrides).toEqual(['malformed', { merge: {} }]); + expect(config.elements).toEqual([ + { + type: 'custom:advanced-camera-card-conditional', + conditions: [ + { condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }, + ], + }, + ]); + }); + }); + + describe('trigger template paths -> top-level trigger.*', () => { + it('should rewrite every legacy trigger path in an automation action', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + triggers: [{ trigger: 'state', entity_id: 'binary_sensor.door' }], + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'log', + message: + '{{ acc.trigger.state.entity }} {{ acc.trigger.state.from }} ' + + '{{ acc.trigger.state.to }} {{ acc.trigger.camera.from }} ' + + '{{ acc.trigger.camera.to }} {{ acc.trigger.view.from }} ' + + '{{ acc.trigger.view.to }} {{ acc.trigger.config.from }} ' + + '{{ acc.trigger.config.to }}', + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0].actions[0].message).toBe( + '{{ trigger.entity_id }} {{ trigger.from_state.state }} ' + + '{{ trigger.to_state.state }} {{ trigger.from_acc.camera }} ' + + '{{ trigger.to_acc.camera }} {{ trigger.from_acc.view }} ' + + '{{ trigger.to_acc.view }} {{ trigger.from_acc.config }} ' + + '{{ trigger.to_acc.config }}', + ); + postUpgradeChecks(config); + }); + + it('should also migrate the long advanced_camera_card prefix', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + triggers: [{ trigger: 'state', entity_id: 'binary_sensor.door' }], + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'log', + message: '{{ advanced_camera_card.trigger.state.to }}', + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0].actions[0].message).toBe( + '{{ trigger.to_state.state }}', + ); + postUpgradeChecks(config); + }); + + it('should rewrite paths in a non-automation menu action', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'custom:advanced-camera-card-menu-icon', + icon: 'mdi:cctv', + tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'camera_select', + camera: '{{ acc.trigger.camera.to }}', + }, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.elements[0].tap_action.camera).toBe('{{ trigger.to_acc.camera }}'); + postUpgradeChecks(config); + }); + + it('should not rewrite a string without a template marker', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + triggers: [{ trigger: 'state', entity_id: 'binary_sensor.door' }], + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'log', + // No `{{` or `{%`, so it is opaque text, not a template, and is + // left alone. + message: 'see acc.trigger.state.to for details', + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeFalsy(); + expect(config.automations[0].actions[0].message).toBe( + 'see acc.trigger.state.to for details', + ); + postUpgradeChecks(config); + }); + + it('should migrate a path inside a statement-only template', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + triggers: [{ trigger: 'state', entity_id: 'binary_sensor.door' }], + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'log', + // A `{% %}` statement with no `{{` is still a template. + message: "{% if acc.trigger.state.to == 'on' %}on{% endif %}", + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0].actions[0].message).toBe( + "{% if trigger.to_state.state == 'on' %}on{% endif %}", + ); + postUpgradeChecks(config); + }); + + it('should migrate the config trigger path', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + triggers: [{ trigger: 'config' }], + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'log', + message: '{{ acc.trigger.config.to }}', + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0].actions[0].message).toBe( + '{{ trigger.to_acc.config }}', + ); + postUpgradeChecks(config); + }); + + it('should be idempotent', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + triggers: [{ trigger: 'state', entity_id: 'binary_sensor.door' }], + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'log', + message: '{{ acc.trigger.state.to }}', + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(upgradeConfig(config)).toBeFalsy(); + expect(config.automations[0].actions[0].message).toBe( + '{{ trigger.to_state.state }}', + ); + }); + }); + + describe('ambient advanced_camera_card.* -> acc.*', () => { + it('should rewrite the ambient namespace in a template', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'custom:advanced-camera-card-menu-icon', + icon: 'mdi:cctv', + tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'log', + message: + '{{ advanced_camera_card.camera }} / {{ advanced_camera_card.view }}', + }, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.elements[0].tap_action.message).toBe( + '{{ acc.camera }} / {{ acc.view }}', + ); + postUpgradeChecks(config); + }); + + it('should leave the short acc spelling untouched', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'custom:advanced-camera-card-menu-icon', + icon: 'mdi:cctv', + tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'log', + message: '{{ acc.camera }}', + }, + }, + ], + }; + expect(upgradeConfig(config)).toBeFalsy(); + expect(config.elements[0].tap_action.message).toBe('{{ acc.camera }}'); + }); + }); + + describe('cameras[].triggers.events string[] -> triggers.media_events', () => { + it('should migrate a legacy string array to media_events', () => { const config = { type: 'custom:advanced-camera-card', cameras: [ @@ -4070,7 +5219,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('migrates an empty legacy array', () => { + it('should migrate an empty legacy array', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office', triggers: { events: [] } }], @@ -4080,7 +5229,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('leaves the new object-array shape untouched', () => { + it('should leave the new object-array shape untouched', () => { const config = { type: 'custom:advanced-camera-card', cameras: [ @@ -4099,7 +5248,7 @@ describe('should handle version specific upgrades', () => { }); }); - it('drops legacy events but keeps an existing media_events untouched', () => { + it('should drop legacy events but keep an existing media_events untouched', () => { // Both fields present is implausible in real user configs, but if it // happens we still must remove the legacy `events: string[]` because // the new schema would reject it; the explicit `media_events` wins. @@ -4122,7 +5271,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('is idempotent across repeated runs', () => { + it('should be idempotent across repeated runs', () => { const config = { type: 'custom:advanced-camera-card', cameras: [ @@ -4137,7 +5286,7 @@ describe('should handle version specific upgrades', () => { expect(config.cameras[0].triggers).toEqual({ media_events: ['events'] }); }); - it('migrates per camera independently', () => { + it('should migrate per camera independently', () => { const config = { type: 'custom:advanced-camera-card', cameras: [ @@ -4157,7 +5306,7 @@ describe('should handle version specific upgrades', () => { expect(config.cameras[2].triggers).toBeUndefined(); }); - it('migrates a legacy cameras_global.triggers.events string array', () => { + it('should migrate a legacy cameras_global.triggers.events string array', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -4174,7 +5323,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('leaves cameras_global new-shape events untouched', () => { + it('should leave cameras_global new-shape events untouched', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -4190,7 +5339,7 @@ describe('should handle version specific upgrades', () => { }); }); - it('is a no-op when triggers is not an object (malformed user config)', () => { + it('should be a no-op when triggers is not an object', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office', triggers: 'not-an-object' }], @@ -4200,8 +5349,8 @@ describe('should handle version specific upgrades', () => { }); }); - describe('live_substream_{on,off,select} → substream_{on,off}', () => { - it('rewrites live_substream_on to substream_on in an automation', () => { + describe('live_substream_{on,off,select} -> substream_{on,off}', () => { + it('should rewrite live_substream_on -> substream_on in an automation', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -4227,7 +5376,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('rewrites live_substream_off to substream_off in an automation', () => { + it('should rewrite live_substream_off -> substream_off in an automation', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -4253,7 +5402,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('rewrites live_substream_select to substream_on with camera → stream', () => { + it('should rewrite live_substream_select -> substream_on with camera -> stream', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -4281,7 +5430,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('rewrites a malformed live_substream_select with no camera field', () => { + it('should rewrite a malformed live_substream_select with no camera field', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -4307,7 +5456,7 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('migrates actions on elements and overrides', () => { + it('should migrate actions on elements and overrides', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -4353,16 +5502,16 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('leaves opaque user payloads alone', () => { + it('should leave opaque user payloads alone', () => { // A perform-action service payload that happens to mention the legacy - // action string in its `data` block must not be rewritten — `data` is + // action string in its `data` block must not be rewritten -- `data` is // opaque, not an action. const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], automations: [ { - conditions: [{ condition: 'initialized' }], + triggers: [{ trigger: 'initialized' }], actions: [ { action: 'perform-action', @@ -4387,7 +5536,7 @@ describe('should handle version specific upgrades', () => { }); }); - it('is idempotent', () => { + it('should be idempotent', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], diff --git a/tests/config/schema/condition-trigger/conditions/custom/user-agent.test.ts b/tests/config/schema/condition-trigger/conditions/custom/user-agent.test.ts new file mode 100644 index 00000000..cc3db38a --- /dev/null +++ b/tests/config/schema/condition-trigger/conditions/custom/user-agent.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { userAgentConditionSchema } from '../../../../../../src/config/schema/condition-trigger/conditions/custom/user-agent'; + +describe('userAgentConditionSchema', () => { + it('should reject a user_agent condition with no fields', () => { + expect(() => userAgentConditionSchema.parse({ condition: 'user_agent' })).toThrow(); + }); + + it('should accept a user_agent condition constrained by user_agent', () => { + expect( + userAgentConditionSchema.parse({ condition: 'user_agent', user_agent: 'Foo' }), + ).toEqual({ condition: 'user_agent', user_agent: 'Foo' }); + }); + + it('should accept a user_agent condition constrained by user_agent_re', () => { + expect( + userAgentConditionSchema.parse({ condition: 'user_agent', user_agent_re: 'Foo' }), + ).toEqual({ condition: 'user_agent', user_agent_re: 'Foo' }); + }); + + it('should accept a user_agent condition constrained by casting', () => { + expect( + userAgentConditionSchema.parse({ condition: 'user_agent', casting: true }), + ).toEqual({ condition: 'user_agent', casting: true }); + }); + + it('should accept a user_agent condition constrained by companion', () => { + expect( + userAgentConditionSchema.parse({ condition: 'user_agent', companion: true }), + ).toEqual({ condition: 'user_agent', companion: true }); + }); +}); diff --git a/tests/config/schema/condition-trigger/conditions/stock/entity-base.test.ts b/tests/config/schema/condition-trigger/conditions/stock/entity-base.test.ts new file mode 100644 index 00000000..02338e0d --- /dev/null +++ b/tests/config/schema/condition-trigger/conditions/stock/entity-base.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { entityConditionBaseSchema } from '../../../../../../src/config/schema/condition-trigger/conditions/stock/entity-base'; + +describe('entityConditionBaseSchema', () => { + it('should reject when neither entity nor entity_id are provided', () => { + expect(() => entityConditionBaseSchema.parse({})).toThrow(); + }); +}); diff --git a/tests/config/schema/condition-trigger/conditions/stock/numeric-state.test.ts b/tests/config/schema/condition-trigger/conditions/stock/numeric-state.test.ts new file mode 100644 index 00000000..b535a5ee --- /dev/null +++ b/tests/config/schema/condition-trigger/conditions/stock/numeric-state.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { numericStateConditionSchema } from '../../../../../../src/config/schema/condition-trigger/conditions/stock/numeric-state'; + +describe('numericStateConditionSchema', () => { + it('should reject when neither above nor below is provided', () => { + expect(() => + numericStateConditionSchema.parse({ + condition: 'numeric_state', + entity_id: 'sensor.temperature', + }), + ).toThrow(); + }); +}); diff --git a/tests/config/schema/condition-trigger/conditions/stock/state.test.ts b/tests/config/schema/condition-trigger/conditions/stock/state.test.ts new file mode 100644 index 00000000..1bdb675f --- /dev/null +++ b/tests/config/schema/condition-trigger/conditions/stock/state.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { stateConditionSchema } from '../../../../../../src/config/schema/condition-trigger/conditions/stock/state'; + +describe('stateConditionSchema', () => { + it('should reject a state condition without state or state_not', () => { + expect(() => + stateConditionSchema.parse({ + condition: 'state', + entity_id: 'binary_sensor.door', + }), + ).toThrow(); + }); + + it('should accept a state condition with state', () => { + expect( + stateConditionSchema.parse({ + condition: 'state', + entity_id: 'binary_sensor.door', + state: 'on', + }), + ).toEqual({ condition: 'state', entity_id: 'binary_sensor.door', state: 'on' }); + }); + + it('should accept a state condition with state_not', () => { + expect( + stateConditionSchema.parse({ + condition: 'state', + entity_id: 'binary_sensor.door', + state_not: 'on', + }), + ).toEqual({ condition: 'state', entity_id: 'binary_sensor.door', state_not: 'on' }); + }); +}); diff --git a/tests/config/schema/condition-trigger/parity.test.ts b/tests/config/schema/condition-trigger/parity.test.ts new file mode 100644 index 00000000..b2367371 --- /dev/null +++ b/tests/config/schema/condition-trigger/parity.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { conditionUnion } from '../../../../src/config/schema/condition-trigger/conditions/types'; +import { triggerSchema } from '../../../../src/config/schema/condition-trigger/triggers/types'; + +// Reads the discriminator literal (`condition`/`trigger`) from every member of +// a union, unwrapping the optional discriminator the state condition uses. +const getTypes = ( + options: readonly z.ZodType[], + key: 'condition' | 'trigger', +): Set => { + const result = new Set(); + for (const option of options) { + if (!(option instanceof z.ZodObject)) { + continue; + } + const field = option.shape[key]; + const literal = field instanceof z.ZodOptional ? field.unwrap() : field; + if (literal instanceof z.ZodLiteral) { + result.add(String(literal.value)); + } + } + return result; +}; + +// HA conditions have `or`/`and`/`not` composites; HA triggers do not (a flat +// trigger list is an implicit OR). So triggers = conditions minus the +// composites. +const COMPOSITES = ['or', 'and', 'not']; + +// `config` only ever detects a change, so it is a trigger but not a condition. +const TRIGGER_ONLY = ['config']; + +// `user`/`user_agent` are static per session, so they are conditions but not +// triggers. +const CONDITION_ONLY = ['user', 'user_agent']; + +// Verify the same (except composite) types are defined in both condition and +// trigger schemas. +describe('condition/trigger schema parity', () => { + it('should keep condition and trigger leaf types in sync', () => { + // Apart from the documented exceptions below, conditions and triggers stay + // paired: adding a leaf type to one without the other (or without recording + // it as an exception) is a bug (the fields may still differ). + const conditionTypes = getTypes(conditionUnion.options, 'condition'); + const triggerTypes = getTypes(triggerSchema.options, 'trigger'); + + // Triggers have no composites (as per HA standard). + expect(COMPOSITES.some((c) => triggerTypes.has(c))).toBe(false); + + // Conditions have composites. + expect(COMPOSITES.every((c) => conditionTypes.has(c))).toBe(true); + + // TRIGGER_ONLY are ... trigger-only. + expect(TRIGGER_ONLY.every((c) => triggerTypes.has(c))).toBe(true); + expect(TRIGGER_ONLY.some((c) => conditionTypes.has(c))).toBe(false); + + // CONDITION_ONLY are ... condition-only. + expect(CONDITION_ONLY.every((c) => conditionTypes.has(c))).toBe(true); + expect(CONDITION_ONLY.some((c) => triggerTypes.has(c))).toBe(false); + + // Otherwise the leaf type sets are identical. + const conditionLeaves = [...conditionTypes].filter( + (t) => !COMPOSITES.includes(t) && !CONDITION_ONLY.includes(t), + ); + const triggerLeaves = [...triggerTypes].filter((t) => !TRIGGER_ONLY.includes(t)); + + expect(triggerLeaves.sort()).toEqual(conditionLeaves.sort()); + }); +}); diff --git a/tests/config/schema/condition-trigger/triggers/stock/entity-base.test.ts b/tests/config/schema/condition-trigger/triggers/stock/entity-base.test.ts new file mode 100644 index 00000000..059ae32d --- /dev/null +++ b/tests/config/schema/condition-trigger/triggers/stock/entity-base.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { entityTriggerBaseSchema } from '../../../../../../src/config/schema/condition-trigger/triggers/stock/entity-base'; + +describe('entityTriggerBaseSchema', () => { + it('should reject when neither entity nor entity_id are provided', () => { + expect(() => entityTriggerBaseSchema.parse({})).toThrow(); + }); +}); diff --git a/tests/config/schema/condition-trigger/triggers/stock/numeric-state.test.ts b/tests/config/schema/condition-trigger/triggers/stock/numeric-state.test.ts new file mode 100644 index 00000000..56dd2c4e --- /dev/null +++ b/tests/config/schema/condition-trigger/triggers/stock/numeric-state.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { numericStateTriggerSchema } from '../../../../../../src/config/schema/condition-trigger/triggers/stock/numeric-state'; + +describe('numericStateTriggerSchema', () => { + it('should reject a numeric_state trigger with neither above nor below', () => { + expect(() => + numericStateTriggerSchema.parse({ + trigger: 'numeric_state', + entity_id: 'sensor.temperature', + }), + ).toThrow(); + }); + + it('should reject a literal above greater than below (an impossible band)', () => { + expect(() => + numericStateTriggerSchema.parse({ + trigger: 'numeric_state', + entity_id: 'sensor.temperature', + above: 20, + below: 10, + }), + ).toThrow(); + }); + + it('should accept a literal above not greater than below', () => { + expect( + numericStateTriggerSchema.parse({ + trigger: 'numeric_state', + entity_id: 'sensor.temperature', + above: 10, + below: 20, + }), + ).toEqual({ + trigger: 'numeric_state', + entity_id: 'sensor.temperature', + above: 10, + below: 20, + }); + }); + + it('should accept an entity-reference threshold', () => { + expect( + numericStateTriggerSchema.parse({ + trigger: 'numeric_state', + entity_id: 'sensor.temperature', + above: 'input_number.high', + below: 10, + }), + ).toEqual({ + trigger: 'numeric_state', + entity_id: 'sensor.temperature', + above: 'input_number.high', + below: 10, + }); + }); + + it('should accept a single threshold', () => { + expect( + numericStateTriggerSchema.parse({ + trigger: 'numeric_state', + entity_id: 'sensor.temperature', + above: 25, + }), + ).toEqual({ + trigger: 'numeric_state', + entity_id: 'sensor.temperature', + above: 25, + }); + }); +}); diff --git a/tests/config/schema/condition-trigger/triggers/stock/state.test.ts b/tests/config/schema/condition-trigger/triggers/stock/state.test.ts new file mode 100644 index 00000000..587f250e --- /dev/null +++ b/tests/config/schema/condition-trigger/triggers/stock/state.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { stateTriggerSchema } from '../../../../../../src/config/schema/condition-trigger/triggers/stock/state'; + +// Covers the card's own refinements (zod pass-through is trusted): HA makes +// `from`/`not_from` and `to`/`not_to` mutually exclusive. +describe('stateTriggerSchema', () => { + it('should reject from together with not_from', () => { + expect(() => + stateTriggerSchema.parse({ + trigger: 'state', + entity_id: 'binary_sensor.door', + from: 'off', + not_from: 'unavailable', + }), + ).toThrow(); + }); + + it('should reject to together with not_to', () => { + expect(() => + stateTriggerSchema.parse({ + trigger: 'state', + entity_id: 'binary_sensor.door', + to: 'on', + not_to: 'unavailable', + }), + ).toThrow(); + }); +}); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index fada62c7..dcdef0ea 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from 'vitest'; import { advancedCameraCardCustomActionsBaseSchema } from '../../src/config/schema/actions/custom/base'; -import { statusBarActionConfigSchema } from '../../src/config/schema/actions/types'; +import { + actionConfigSchema, + statusBarActionConfigSchema, +} from '../../src/config/schema/actions/types'; +import { automationsSchema } from '../../src/config/schema/automations'; import { cameraConfigSchema } from '../../src/config/schema/cameras'; -import { advancedCameraCardConditionSchema } from '../../src/config/schema/conditions/types'; +import { conditionSchema } from '../../src/config/schema/condition-trigger/conditions/types'; import { dimensionsConfigSchema } from '../../src/config/schema/dimensions'; import { customSchema } from '../../src/config/schema/elements/stock/custom'; import { conditionalSchema } from '../../src/config/schema/elements/types'; @@ -692,7 +696,7 @@ describe('config defaults', () => { conditions: [ { condition: 'state', - entity: 'light.office_main_lights', + entity_id: 'light.office_main_lights', state: 'on', state_not: 'off', }, @@ -889,7 +893,6 @@ describe('config defaults', () => { { condition: 'and', conditions: [{ condition: 'initialized' }] }, { 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 }, @@ -909,7 +912,7 @@ describe('config defaults', () => { { condition: 'not', conditions: [{ condition: 'initialized' }] }, { condition: 'numeric_state', - entity: 'sensor.office_temperature', + entity_id: 'sensor.office_temperature', above: 10, below: 20, }, @@ -917,7 +920,7 @@ describe('config defaults', () => { { condition: 'screen', media_query: '(orientation: landscape)' }, { condition: 'state', - entity: 'climate.office', + entity_id: 'climate.office', state: 'heat', state_not: 'off', }, @@ -1323,7 +1326,7 @@ it('should transform dimensions.aspect_ratio', () => { describe('should refine user_agent_re conditions', () => { it('should successfully parse valid user_agent_re condition', () => { expect( - advancedCameraCardConditionSchema.parse({ + conditionSchema.parse({ condition: 'user_agent', user_agent_re: 'Chrome/', }), @@ -1335,7 +1338,7 @@ describe('should refine user_agent_re conditions', () => { it('should reject invalid user_agent_re conditions', () => { expect(() => - advancedCameraCardConditionSchema.parse({ + conditionSchema.parse({ condition: 'user_agent', user_agent_re: '[', }), @@ -1343,6 +1346,76 @@ describe('should refine user_agent_re conditions', () => { }); }); +describe('conditions should accept Home Assistant composite shorthand', () => { + it('should expand and/or/not operator shorthand', () => { + for (const op of ['and', 'or', 'not'] as const) { + expect( + conditionSchema.parse({ + [op]: [{ condition: 'fullscreen', fullscreen: true }], + }), + ).toMatchObject({ + condition: op, + conditions: [{ condition: 'fullscreen', fullscreen: true }], + }); + } + }); + + it('should expand a condition list to an implicit and', () => { + expect( + conditionSchema.parse({ + condition: [ + { condition: 'fullscreen', fullscreen: true }, + { condition: 'expand', expand: true }, + ], + }), + ).toMatchObject({ + condition: 'and', + conditions: [ + { condition: 'fullscreen', fullscreen: true }, + { condition: 'expand', expand: true }, + ], + }); + }); + + it('should normalise a single shorthand condition to a list', () => { + expect( + conditionSchema.parse({ or: { condition: 'fullscreen', fullscreen: true } }), + ).toMatchObject({ + condition: 'or', + conditions: [{ condition: 'fullscreen', fullscreen: true }], + }); + }); + + it('should expand nested shorthand and preserve base fields', () => { + expect( + conditionSchema.parse({ + or: [{ and: [{ condition: 'fullscreen', fullscreen: true }] }], + enabled: false, + }), + ).toMatchObject({ + condition: 'or', + enabled: false, + conditions: [ + { + condition: 'and', + conditions: [{ condition: 'fullscreen', fullscreen: true }], + }, + ], + }); + }); + + it('should leave a canonical condition untouched', () => { + expect( + conditionSchema.parse({ condition: 'fullscreen', fullscreen: true }), + ).toMatchObject({ condition: 'fullscreen', fullscreen: true }); + }); + + it('should reject ambiguous or non-record shorthand', () => { + expect(conditionSchema.safeParse({ and: [], or: [] }).success).toBe(false); + expect(conditionSchema.safeParse('nope').success).toBe(false); + }); +}); + it('should transform action', () => { expect( advancedCameraCardCustomActionsBaseSchema.parse({ @@ -1582,7 +1655,7 @@ describe('should lazy evaluate schemas', () => { conditions: [ { condition: 'state', - entity: 'light.office_main_lights', + entity_id: 'light.office_main_lights', state: 'on', state_not: 'off', }, @@ -1603,7 +1676,7 @@ describe('should lazy evaluate schemas', () => { conditions: [ { condition: 'state', - entity: 'light.office_main_lights', + entity_id: 'light.office_main_lights', state: 'on', state_not: 'off', }, @@ -1643,6 +1716,90 @@ describe('should lazy evaluate schemas', () => { }; expect(statusBarActionConfigSchema.parse(input)).toEqual(input); }); + + it('should recursively validate if action then/else sequences', () => { + const input = { + 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', + }, + ], + }; + expect(actionConfigSchema.parse(input)).toEqual(input); + + // The then/else sequences are validated as actions (not accepted verbatim). + expect( + actionConfigSchema.safeParse({ ...input, then: [{ action: 'not-an-action' }] }) + .success, + ).toBeFalsy(); + }); + + it('should normalise single if/then/else items to lists', () => { + const result = actionConfigSchema.parse({ + if: { condition: 'state', entity_id: 'light.office', 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', + }, + }); + + expect(result).toMatchObject({ + if: [{ condition: 'state', entity_id: 'light.office', 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' }, + ], + }); + }); +}); + +describe('should apply specific custom action schemas, not the generic catch-all', () => { + it('should default a log action level to info', () => { + // The generic `customActionSchema` (a loose `fire-dom-event` matcher) must + // not shadow `logActionConfigSchema`, or the `level` default is lost and the + // action crashes at runtime. + expect( + actionConfigSchema.parse({ + action: 'fire-dom-event', + advanced_camera_card_action: 'log', + message: 'hello', + }), + ).toMatchObject({ advanced_camera_card_action: 'log', level: 'info' }); + }); + + it('should default a log action level nested in an if branch', () => { + const result = actionConfigSchema.parse({ + if: { condition: 'state', entity_id: 'light.office', state: 'on' }, + then: { + action: 'fire-dom-event', + advanced_camera_card_action: 'log', + message: 'hello', + }, + }); + expect(result).toMatchObject({ + then: [{ advanced_camera_card_action: 'log', level: 'info' }], + }); + }); }); describe('should handle custom advanced camera card elements', () => { @@ -1717,40 +1874,56 @@ it('media viewer should not support microphone based conditions', () => { ).toThrowError(); }); -describe('automations should require at least one action', () => { - it('should handle no action', () => { +describe('automations should require actions', () => { + it('should reject a missing actions key', () => { expect(() => createConfig({ cameras: [{}], - automations: [{ conditions: [] }], + automations: [{ triggers: [{ trigger: 'initialized' }], conditions: [] }], }), - ).toThrowError(/Automations must include at least one action/); + ).toThrowError(); }); +}); - it('should handle empty actions', () => { - expect(() => - createConfig({ - cameras: [{}], - automations: [{ conditions: [], actions: [], actions_not: [] }], - }), - ).toThrowError(/Automations must include at least one action/); +describe('automations should accept Home Assistant input shorthands', () => { + it('should normalise singular keys and single items to lists', () => { + const result = automationsSchema.parse([ + { + trigger: { trigger: 'state', entity_id: 'binary_sensor.door', to: 'on' }, + condition: { condition: 'state', entity_id: 'input_boolean.x', state: 'on' }, + action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'live_substream_on', + }, + }, + ]); + + expect(result).toMatchObject([ + { + triggers: [{ trigger: 'state', entity_id: 'binary_sensor.door', to: 'on' }], + conditions: [{ condition: 'state', entity_id: 'input_boolean.x', state: 'on' }], + actions: [ + { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_on' }, + ], + }, + ]); }); - it('should handle at least one action', () => { - expect(() => - createConfig({ - cameras: [{}], - automations: [ - { - conditions: [], - actions: [ - { - action: 'fire-dom-event', - }, - ], - }, + it('should keep the plural key when both singular and plural are given', () => { + const result = automationsSchema.parse([ + { + triggers: [{ trigger: 'initialized' }], + trigger: { trigger: 'state', entity_id: 'x', to: 'on' }, + actions: [ + { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_on' }, ], - }), - ).not.toThrowError(); + }, + ]); + + expect(result[0].triggers).toEqual([{ trigger: 'initialized' }]); + }); + + it('should reject a non-object automation', () => { + expect(automationsSchema.safeParse(['not-an-object']).success).toBe(false); }); }); diff --git a/tests/templates/index.test.ts b/tests/templates/index.test.ts index 105ce572..6bd4341d 100644 --- a/tests/templates/index.test.ts +++ b/tests/templates/index.test.ts @@ -1,7 +1,8 @@ import { renderTemplate } from 'ha-nunjucks'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TemplateRenderer } from '../../src/card-controller/templates'; -import { ConditionsTriggerData, ConditionState } from '../../src/conditions/types'; +import { ConditionState } from '../../src/condition-trigger/conditions/types'; +import { TriggerData } from '../../src/condition-trigger/triggers/types'; import { createHASS } from '../test-utils'; // ha-nunjucks attempts to make websocket calls initially so mock it out. @@ -46,11 +47,11 @@ describe('TemplateRenderer', () => { camera: 'camera', view: 'live', }; - const triggerData: ConditionsTriggerData = { - camera: { - to: 'camera', - from: 'previous-camera', - }, + const triggerData: TriggerData = { + platform: 'acc', + type: 'camera', + from_acc: { camera: 'previous-camera' }, + to_acc: { camera: 'camera' }, }; const renderer = new TemplateRenderer(); @@ -71,22 +72,12 @@ describe('TemplateRenderer', () => { acc: { camera: 'camera', view: 'live', - trigger: { - camera: { - to: 'camera', - from: 'previous-camera', - }, - }, }, - advanced_camera_card: { - camera: 'camera', - view: 'live', - trigger: { - camera: { - to: 'camera', - from: 'previous-camera', - }, - }, + trigger: { + platform: 'acc', + type: 'camera', + from_acc: { camera: 'previous-camera' }, + to_acc: { camera: 'camera' }, }, }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 26f93ddf..f6c45d60 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -24,6 +24,7 @@ import { import { ActionsManager } from '../src/card-controller/actions/actions-manager'; import { AutomationsManager } from '../src/card-controller/automations-manager'; import { CallManager } from '../src/card-controller/call/manager'; +import { CameraTriggersManager } from '../src/card-controller/camera-triggers-manager'; import { CameraURLManager } from '../src/card-controller/camera-url-manager'; import { CardElementManager, @@ -37,8 +38,8 @@ import { ExpandManager } from '../src/card-controller/expand-manager'; import { FoldersManager } from '../src/card-controller/folders/manager'; import { FolderQuery } from '../src/card-controller/folders/types'; import { FullscreenManager } from '../src/card-controller/fullscreen/fullscreen-manager'; -import { HASSManager } from '../src/card-controller/hass/hass-manager'; import { EventWatcherSubscriptionInterface } from '../src/card-controller/hass/event-watcher'; +import { HASSManager } from '../src/card-controller/hass/hass-manager'; import { StateWatcherSubscriptionInterface } from '../src/card-controller/hass/state-watcher'; import { InitializationManager } from '../src/card-controller/initialization-manager'; import { InteractionManager } from '../src/card-controller/interaction-manager'; @@ -54,11 +55,10 @@ import { PIPManager } from '../src/card-controller/pip-manager'; import { QueryStringManager } from '../src/card-controller/query-string-manager'; import { StatusBarItemManager } from '../src/card-controller/status-bar-item-manager'; import { StyleManager } from '../src/card-controller/style-manager'; -import { TriggersManager } from '../src/card-controller/triggers-manager'; import { ViewItemManager } from '../src/card-controller/view/item-manager'; import { ViewManager } from '../src/card-controller/view/view-manager'; import { SubmenuInteraction, SubmenuItem } from '../src/components/submenu/types'; -import { ConditionStateManager } from '../src/conditions/state-manager'; +import { ConditionStateManager } from '../src/condition-trigger/conditions/state-manager'; import { CameraConfig, cameraConfigSchema } from '../src/config/schema/cameras'; import { FolderConfig } from '../src/config/schema/folders'; import { @@ -725,7 +725,7 @@ export const createCardAPI = (): CardController => { api.getQueryStringManager.mockReturnValue(mock()); api.getStatusBarItemManager.mockReturnValue(mock()); api.getStyleManager.mockReturnValue(mock()); - api.getTriggersManager.mockReturnValue(mock()); + api.getCameraTriggersManager.mockReturnValue(mock()); api.getViewItemManager.mockReturnValue(mock()); api.getViewManager.mockReturnValue(mock()); diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index af621e04..2b8f2c65 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -25,7 +25,9 @@ import { createSubstreamOnAction, createViewAction, getActionConfigGivenAction, + getActionName, hasAction, + isIfAction, stopEventFromActivatingCardWideActions, } from '../../src/utils/action.js'; @@ -563,6 +565,34 @@ describe('hasAction', () => { it('should return false with an array of actions none real', () => { expect(hasAction([noneAction, noneAction, noneAction])).toBeFalsy(); }); + + it('should return true for an if action', () => { + expect(hasAction({ if: [], then: [] })).toBeTruthy(); + }); +}); + +describe('isIfAction', () => { + it('should return true for an if action', () => { + expect(isIfAction({ if: [], then: [] })).toBeTruthy(); + }); + + it('should return false for a stock action', () => { + expect(isIfAction({ action: 'none' })).toBeFalsy(); + }); +}); + +describe('getActionName', () => { + it('should name an if action', () => { + expect(getActionName({ if: [], then: [] })).toBe('if'); + }); + + it('should name a custom action', () => { + expect(getActionName(createViewAction('clips'))).toBe('clips'); + }); + + it('should name a stock action', () => { + expect(getActionName({ action: 'more-info' })).toBe('more-info'); + }); }); // @vitest-environment jsdom diff --git a/tests/utils/keyed-subscription-manager.test.ts b/tests/utils/concurrency/keyed-subscription-manager.test.ts similarity index 97% rename from tests/utils/keyed-subscription-manager.test.ts rename to tests/utils/concurrency/keyed-subscription-manager.test.ts index d4abbdbb..15a416c7 100644 --- a/tests/utils/keyed-subscription-manager.test.ts +++ b/tests/utils/concurrency/keyed-subscription-manager.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { KeyedSubscriptionManager } from '../../src/utils/keyed-subscription-manager'; +import { KeyedSubscriptionManager } from '../../../src/utils/concurrency/keyed-subscription-manager'; interface TestRequest { key: string; diff --git a/tests/utils/concurrency/serial-runner.test.ts b/tests/utils/concurrency/serial-runner.test.ts new file mode 100644 index 00000000..0c184d80 --- /dev/null +++ b/tests/utils/concurrency/serial-runner.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { SerialRunner } from '../../../src/utils/concurrency/serial-runner'; + +describe('SerialRunner', () => { + it('should run work immediately and return its result', () => { + const runner = new SerialRunner(); + + expect(runner.run(() => 42)).toBe(42); + }); + + it('should defer work run from within work and return null for it', () => { + const runner = new SerialRunner(); + const order: string[] = []; + let deferredResult: number | null = -1; + + const result = runner.run(() => { + order.push('outer-start'); + deferredResult = runner.run(() => { + order.push('inner'); + return 2; + }); + order.push('outer-end'); + return 1; + }); + + expect(result).toBe(1); + + // The re-entrant call returns null: its work had not run when it returned. + expect(deferredResult).toBeNull(); + + // The deferred work runs only after the outer work completes, never nested. + expect(order).toEqual(['outer-start', 'outer-end', 'inner']); + }); + + it('should drain multiple re-entrant runs in the order enqueued', () => { + const runner = new SerialRunner(); + const order: string[] = []; + + runner.run(() => { + order.push('outer'); + runner.run(() => order.push('a')); + runner.run(() => order.push('b')); + }); + + expect(order).toEqual(['outer', 'a', 'b']); + }); + + it('should drain work enqueued while draining, preserving order', () => { + const runner = new SerialRunner(); + const order: string[] = []; + + runner.run(() => { + order.push('outer'); + runner.run(() => { + order.push('a'); + + // Enqueued during the drain of 'a', after 'b' is already queued. + runner.run(() => order.push('c')); + }); + runner.run(() => order.push('b')); + }); + + expect(order).toEqual(['outer', 'a', 'b', 'c']); + }); +}); diff --git a/tests/view/unified-query-runner.test.ts b/tests/view/unified-query-runner.test.ts index 93e165ca..573cb572 100644 --- a/tests/view/unified-query-runner.test.ts +++ b/tests/view/unified-query-runner.test.ts @@ -2,7 +2,7 @@ import { assert, describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CameraManager } from '../../src/camera-manager/manager'; import { FoldersManager } from '../../src/card-controller/folders/manager'; -import { ConditionStateManagerReadonlyInterface } from '../../src/conditions/types'; +import { ConditionStateManagerReadonlyInterface } from '../../src/condition-trigger/conditions/types'; import { QuerySource } from '../../src/query-source'; import { ViewMedia } from '../../src/view/item'; import { UnifiedQuery } from '../../src/view/unified-query';