feat: Align automations with Home Assistant triggers and conditions (#2527)
Split automations into HA-style `triggers`, ongoing `conditions`, and
`actions`, with compatibility migrations for existing Advanced Camera
Card configs.
## Summary
At a glance (details below):
- **Added** `triggers:` -- a required, HA-shaped block: stock `state` /
`numeric_state` / `template` plus card-specific triggers (`camera`,
`view`, `fullscreen`, ...).
- **Added** the HA-native `if` / `then` / `else` action.
- **Removed** `actions_not` (replaced by `if` / `then` / `else`).
- **Removed** the ambient `advanced_camera_card` template namespace (use
`acc` instead).
- **Changed** the trigger template surface to a top-level `trigger.*`
variable (as in HA); the nested `acc.trigger.*` paths are removed.
- **Changed** `conditions:` to ongoing gates only -- they no longer wake
an automation, and change-only forms (`config`, valueless `camera` /
`view` / `state`) become triggers, not conditions.
- **Changed** action templates to render per step, so a later action
sees state an earlier one changed.
- **Compatibility:** HA-shaped YAML is accepted (singular keys,
single-or-list, `and` / `or` / `not` shorthand, `entity` / `entity_id`).
- **Migration:** existing configs upgrade automatically; anything that
cannot be converted faithfully is recorded under `__UPGRADE_FAILURE__`
for manual fixup.
## Breaking Changes
### 1. Automations now require triggers
Before this PR, `automations[].conditions` served two roles:
- They decided whether the automation should run.
- They also acted as the thing that woke the automation up.
After this PR:
- `triggers` wake the automation.
- `conditions` only gate it at the instant a trigger fires.
Most existing automations are migrated automatically from `conditions:`
to `triggers:`.
### 2. `actions_not` is retired
Legacy `actions_not` is replaced by an HA-style `if` action with `then`
/ `else`.
Faithful conversions are automatic. Cases that cannot be faithfully
converted are recorded under `__UPGRADE_FAILURE__.automations` and must
be migrated manually.
### 3. Template surface aligned with Home Assistant
Two related template changes, both auto-migrated:
- **Top-level `trigger.*`.** Automation actions now receive a top-level
`trigger` template variable, like Home Assistant. Legacy nested paths
such as `acc.trigger.state.to` and
`advanced_camera_card.trigger.camera.to` are migrated automatically when
they appear inside template strings.
- **The ambient `advanced_camera_card` template namespace is removed.**
The long-form ambient namespace (`advanced_camera_card.camera`,
`advanced_camera_card.view`, `advanced_camera_card.config`) is retired
in favour of its shorter `acc` alias -- supported since v7.1.0, and the
only spelling the new trigger surface uses. Existing templates are
migrated automatically by rewriting the `advanced_camera_card.` prefix
to `acc.`.
### 4. Trigger-only condition forms are no longer valid conditions
Some legacy "conditions" were really change detectors. These are now
triggers only:
- `condition: config`
- valueless `camera`
- valueless `view`
- valueless `state` / picture-elements state condition with neither
`state` nor `state_not`
These are automatically promoted in automations and stripped from
overrides/elements where they would no longer be meaningful as ongoing
conditions.
### 5. Template truthiness now follows Home Assistant behavior
Template conditions and template triggers intentionally use different
truthiness rules, matching HA:
- A template condition passes only when the rendered value is `true`
(case-insensitive), matching HA's `condition.py`.
- A template trigger uses HA's broader `result_as_boolean` coercion: a
non-zero number, or `1` / `true` / `yes` / `on` / `enable`
(case-insensitive), counts as true.
### 6. Action templates render when each action executes
Action templates are now rendered per action step, not once for the
whole sequence. This means a later action can see card-local state
changed by an earlier action in the same sequence.
The `trigger` context is fixed for the automation run. HA entity state
updates still depend on the frontend receiving updated HASS state over
the websocket.
## Automatic Migrations
### Automation `conditions:` to `triggers:`
Simple legacy automation:
```yaml
# Before
automations:
- conditions:
- condition: fullscreen
fullscreen: true
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_on
```
```yaml
# After, automatic
automations:
- triggers:
- trigger: fullscreen
fullscreen: true
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_on
```
State conditions become HA-style state triggers:
```yaml
# Before
automations:
- conditions:
- condition: state
entity_id: binary_sensor.front_door
state: 'on'
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: live
```
```yaml
# After, automatic
automations:
- triggers:
- trigger: state
entity_id: binary_sensor.front_door
to: 'on'
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: live
```
Multiple conditions become both triggers and ongoing conditions:
```yaml
# Before
automations:
- conditions:
- condition: camera
cameras: [front_door]
- condition: fullscreen
fullscreen: true
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_on
```
```yaml
# After, automatic
automations:
- triggers:
- trigger: camera
cameras: [front_door]
- trigger: fullscreen
fullscreen: true
conditions:
- condition: camera
cameras: [front_door]
- condition: fullscreen
fullscreen: true
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_on
```
The flattened trigger list is an implicit OR. The retained `conditions:`
list is an implicit AND checked when any trigger fires.
### Trigger-only legacy conditions
Legacy `config` conditions become `config` triggers:
```yaml
# Before
automations:
- conditions:
- condition: config
paths: [menu.style]
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: status_bar
```
```yaml
# After, automatic
automations:
- triggers:
- trigger: config
paths: [menu.style]
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: status_bar
```
Trigger-only leaves are removed from retained `conditions:` blocks
because they no longer describe an ongoing state.
### `actions_not` to `if` / `then` / `else`
```yaml
# Before
automations:
- conditions:
- condition: state
entity_id: input_boolean.camera_alerts
state: 'on'
actions:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: live
actions_not:
- action: none
```
```yaml
# After, automatic
automations:
- triggers:
- trigger: state
entity_id: input_boolean.camera_alerts
actions:
- if:
- condition: state
entity_id: input_boolean.camera_alerts
state: 'on'
then:
- action: custom:advanced-camera-card-action
advanced_camera_card_action: live
else:
- action: none
```
If the legacy automation had no conditions, or only trigger-only
conditions, `actions_not` is dropped because the old `else` branch could
not be reproduced as an ongoing predicate.
### Trigger template paths
```yaml
# Before
message: 'Door is {{ acc.trigger.state.to }} from {{ acc.trigger.state.from }}'
```
```yaml
# After, automatic
message: 'Door is {{ trigger.to_state.state }} from {{ trigger.from_state.state }}'
```
Path rewrites performed automatically:
| Old path | New path |
| -------------------------- | -------------------------- |
| `acc.trigger.state.entity` | `trigger.entity_id` |
| `acc.trigger.state.from` | `trigger.from_state.state` |
| `acc.trigger.state.to` | `trigger.to_state.state` |
| `acc.trigger.camera.from` | `trigger.from_acc.camera` |
| `acc.trigger.camera.to` | `trigger.to_acc.camera` |
| `acc.trigger.view.from` | `trigger.from_acc.view` |
| `acc.trigger.view.to` | `trigger.to_acc.view` |
| `acc.trigger.config.from` | `trigger.from_acc.config` |
| `acc.trigger.config.to` | `trigger.to_acc.config` |
The same rewrites are applied for the older
`advanced_camera_card.trigger.*` namespace.
### Ambient template namespace
Any remaining long-form ambient `advanced_camera_card.*` references
(outside the trigger surface) are rewritten to the `acc.*` alias:
```yaml
# Before
title: 'Now viewing {{ advanced_camera_card.camera }}'
```
```yaml
# After, automatic
title: 'Now viewing {{ acc.camera }}'
```
## Manual Migration Cases
### `__UPGRADE_FAILURE__.automations`
If a legacy automation cannot be converted faithfully, the original
automation is recorded under:
```yaml
__UPGRADE_FAILURE__:
automations:
- ...
```
These entries require manual migration.
The main known case is legacy `actions_not` with a condition whose
trigger can only fire on a rising edge, such as:
- `condition: template`
- `condition: screen`
- `condition: numeric_state` without an entity-backed state to watch
Those conditions can start the `then` branch, but cannot reliably start
the `else` branch when they stop matching.
### Unsupported HA conditions and triggers
This PR aligns the card with HA where supported, but it is not a full HA
automation engine.
Unsupported HA condition families include:
- `time`
- `zone`
- `sun`
- `location`
- `device`
- `condition: trigger`
Unsupported HA trigger platforms include:
- `event`
- `time`
- `time_pattern`
- `sun`
- `zone`
- `calendar`
- `webhook`
- `tag`
- `device`
- `mqtt`
The card-specific camera `triggers:` feature (which auto-selects and
wakes the card on camera events such as motion) is a separate feature
from automation `triggers:`, despite the shared word.
### Trigger IDs and variables
HA keys such as `id`, `alias`, and `variables` are accepted so pasted HA
YAML validates, but they are ignored by the card. There is no
`trigger.id` support in this PR.
## New Compatibility Features
This PR also makes card config more forgiving for HA-style YAML:
- `trigger`, `condition`, and `action` singular keys are accepted and
normalized to `triggers`, `conditions`, and `actions`.
- Single trigger, condition, and action objects are accepted where lists
are expected.
- `if`, `then`, and `else` accept a single item or a list.
- Composite condition shorthand is accepted:
- `{ and: [...] }`
- `{ or: [...] }`
- `{ not: [...] }`
- `{ condition: [...] }` as an implicit AND
- State conditions resolve expected state values that name another
entity, matching HA/Lovelace behavior.
- Both `entity` and `entity_id` are accepted on state and numeric
conditions and triggers (a superset of HA's two dialects), so there is
no forced rename.
- `state_not` remains supported as a card/Lovelace-friendly extension.
## Trigger Payloads
Automation action templates receive a top-level `trigger` object.
For stock `state` and `numeric_state` triggers:
```yaml
trigger.platform
trigger.entity_id
trigger.entity
trigger.from_state
trigger.to_state
```
For template triggers:
```yaml
trigger.platform
```
For card-specific triggers:
```yaml
trigger.platform # "acc"
trigger.type
trigger.from_acc
trigger.to_acc
```
The card does not currently expose HA's `id`, `idx`, `for`, `attribute`,
`above`, `below`, or `alias` trigger fields.
BREAKING CHANGE: Automations now follow Home Assistant's `triggers:` /
`conditions:` / `actions:` model. Automations require a `triggers:`
block and `conditions:` no longer wake an automation; `actions_not` is
removed in favour of an `if` / `then` / `else` action; the nested
`acc.trigger.*` template paths and the ambient `advanced_camera_card`
template namespace are removed (use the top-level `trigger.*` surface
and the `acc` alias); trigger-only condition forms (`config`, valueless
`camera` / `view` / `state`) are no longer valid conditions; and
template-condition vs template-trigger truthiness now follow HA.
Existing configs are upgraded automatically where a faithful conversion
exists; anything that cannot be converted is recorded under
`__UPGRADE_FAILURE__` for manual migration.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
committed by
dermotduffy
co-authored by
Claude Opus 4.8
parent
209c873c58
commit
b701366762
@@ -0,0 +1,70 @@
|
||||
# `condition-trigger`
|
||||
|
||||
The runtime behind `automations:`, `overrides:` and conditional
|
||||
picture-`elements:`, built to mirror Home Assistant's conditions and triggers.
|
||||
User-facing reference: [`conditions-triggers.md`](../../docs/configuration/conditions-triggers.md).
|
||||
|
||||
## Condition vs trigger
|
||||
|
||||
The same type (`state`, `camera`, ...) exists as both, but they are opposite shapes:
|
||||
|
||||
| | Condition | Trigger |
|
||||
| ------- | ------------------------------------------------- | -------------------------------------- |
|
||||
| Asks | _"is this true right now?"_ | _"did this just become true?"_ |
|
||||
| Is a | level / predicate, pulled via `evaluate()` | edge / event, pushed via `subscribe()` |
|
||||
| Used in | `automations` `conditions`, `overrides`, elements | `automations` `triggers` only |
|
||||
|
||||
Both read one source of truth, the **`ConditionStateManager`** -- the card's
|
||||
live state (`camera`/`view`/`config`/`hass`/...), which notifies on change (the
|
||||
lone exception is `screen`, which watches a `window.matchMedia` query). The Zod
|
||||
schema
|
||||
([`config/schema/condition-trigger/`](../config/schema/condition-trigger/))
|
||||
shares each type's fields between its condition and trigger (`common/`) so the
|
||||
two cannot drift.
|
||||
|
||||
## Conditions
|
||||
|
||||
Each type has a pure level-predicate evaluator (`evaluate(state) -> result`),
|
||||
built by `createConditionEvaluator`. An evaluator may also declare
|
||||
`externalSources` -- change sources outside `ConditionState` (currently only
|
||||
`screen`'s `matchMedia`, via a `MediaQueryWatcher`) -- which `ConditionsManager`
|
||||
subscribes to so a change there triggers a re-evaluation. The manager ANDs a set
|
||||
of evaluators and notifies when the combined result flips; it backs
|
||||
`overrides`/`elements` and the automation ongoing-`conditions` pull (below).
|
||||
|
||||
## The bridge
|
||||
|
||||
A type's meaning lives in exactly one place -- its condition evaluator. A
|
||||
card-state trigger reuses that same evaluator as a point-in-time value-filter,
|
||||
built directly from the trigger by `createConditionEvaluatorForTrigger` (the
|
||||
trigger and condition schemas share a `common/` base, so no discriminator-swap
|
||||
or cast). One definition of meaning, two readings: the condition asks the
|
||||
predicate, the trigger watches for change and filters it through the predicate.
|
||||
|
||||
## Triggers: four kinds
|
||||
|
||||
`createTriggerEvaluator` picks one. Each emits a `TriggerData` payload (the
|
||||
`trigger.*` template variable); stock triggers report their HA `platform`, card
|
||||
triggers report `platform: acc` + the kind in `type`.
|
||||
|
||||
1. **Stock entity** (`state`, `numeric_state`) -- `EntityStateTriggerBase`:
|
||||
per-`entity_id` fan-out, HA `from_state`/`to_state`, `for:` via a `Timer`.
|
||||
2. **Stock template** (`template`) -- the non-true -> true edge of `value_template`.
|
||||
3. **Screen** (`screen`) -- `ScreenTrigger`: watches a `matchMedia` query, whose
|
||||
state lives outside `ConditionState`, so it owns a shared `MediaQueryWatcher`
|
||||
(the same watcher the screen condition uses) and fires on the rising edge of
|
||||
the match.
|
||||
4. **Card-state** (every other type: `camera`, `view`, `config`, `fullscreen`,
|
||||
...) -- `ConditionStateTriggerBase`: subscribe to the
|
||||
`ConditionStateManager`, fire when the watched field (`_getValue`) changes,
|
||||
and emit `from_acc`/`to_acc` snapshots. A value filters the change through
|
||||
the matching condition (the bridge); no value fires on any change; `config`
|
||||
is trigger-only (no matching condition).
|
||||
|
||||
## Automations: push, then pull
|
||||
|
||||
`AutomationsManager` runs a `TriggersManager` per automation and, when a trigger
|
||||
pushes, **pull-evaluates** the ongoing `conditions` against the current state
|
||||
(this matches Home Assistant actions). The state store updates _before_
|
||||
dispatching, so a pulled condition already sees the triggering change -- no
|
||||
ordering needed between the two.
|
||||
@@ -0,0 +1,33 @@
|
||||
import { TemplateRenderer } from '../../card-controller/templates';
|
||||
import { ConditionState } from '../conditions/types';
|
||||
|
||||
// The shared `enabled` gate for triggers and conditions (equivalent to HA's
|
||||
// `vol.Any(boolean, template)`): a boolean, or a template rendered against the
|
||||
// current state. Returns whether the trigger/condition is active.
|
||||
//
|
||||
// `enabledWithoutHass` is the fallback when a template `enabled` cannot be
|
||||
// rendered (no hass yet, e.g. at startup). It differs by caller because
|
||||
// "disabled" has opposite consequences: a disabled *trigger* simply does not
|
||||
// fire (so triggers fail closed -- pass `false`), whereas a disabled
|
||||
// *condition* is skipped (so the condition may evaluate to `true`)
|
||||
export const isEnabled = (
|
||||
templateRenderer: TemplateRenderer,
|
||||
enabled?: boolean | string,
|
||||
state?: ConditionState,
|
||||
enabledWithoutHass = true,
|
||||
): boolean => {
|
||||
if (enabled === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (typeof enabled === 'boolean') {
|
||||
return enabled;
|
||||
}
|
||||
if (!state?.hass) {
|
||||
return enabledWithoutHass;
|
||||
}
|
||||
return (
|
||||
templateRenderer.renderRecursively(state.hass, enabled, {
|
||||
conditionState: state,
|
||||
}) === true
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
export type MediaQueryWatcherUnsubscribeCallback = () => void;
|
||||
|
||||
// Watches a single CSS media query via `window.matchMedia`. The shared change
|
||||
// source for the `screen` condition and trigger, whose match state lives
|
||||
// outside the card's `ConditionState`.
|
||||
export class MediaQueryWatcher {
|
||||
private _query: string;
|
||||
private _mediaQuery: MediaQueryList | null = null;
|
||||
private _callback: (() => void) | null = null;
|
||||
|
||||
constructor(query: string) {
|
||||
this._query = query;
|
||||
}
|
||||
|
||||
public matches(): boolean {
|
||||
return window.matchMedia(this._query).matches;
|
||||
}
|
||||
|
||||
public subscribe(callback: () => void): MediaQueryWatcherUnsubscribeCallback {
|
||||
this._callback = callback;
|
||||
this._mediaQuery = window.matchMedia(this._query);
|
||||
this._mediaQuery.addEventListener('change', this._handler);
|
||||
|
||||
return (): void => {
|
||||
this._mediaQuery?.removeEventListener('change', this._handler);
|
||||
this._mediaQuery = null;
|
||||
this._callback = null;
|
||||
};
|
||||
}
|
||||
|
||||
private _handler = (): void => this._callback?.();
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { TemplateRenderer } from '../../card-controller/templates';
|
||||
import { NumericStateBase } from '../../config/schema/condition-trigger/common/numeric-state';
|
||||
import { ConditionState } from '../conditions/types';
|
||||
|
||||
// The numeric value of `entityID` to compare: the rendered `value_template`,
|
||||
// else the `attribute`, else the state. Returns null when the entity is absent
|
||||
// or the value is non-numeric (the cases where HA raises a ConditionError).
|
||||
export const readNumericStateValue = (
|
||||
entityID: string,
|
||||
state: ConditionState,
|
||||
config: NumericStateBase,
|
||||
templateRenderer: TemplateRenderer,
|
||||
): number | null => {
|
||||
const hass = state.hass;
|
||||
if (!hass) {
|
||||
return null;
|
||||
}
|
||||
const stateObj = hass.states?.[entityID];
|
||||
if (!stateObj) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rawValue: unknown;
|
||||
if (config.value_template) {
|
||||
rawValue = templateRenderer.renderRecursively(hass, config.value_template, {
|
||||
conditionState: state,
|
||||
});
|
||||
} else if (config.attribute !== undefined) {
|
||||
rawValue = stateObj.attributes?.[config.attribute];
|
||||
} else {
|
||||
rawValue = stateObj.state;
|
||||
}
|
||||
|
||||
const value = Number(rawValue);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
};
|
||||
|
||||
// Whether `entityID`'s numeric value currently satisfies the `above`/`below`
|
||||
// thresholds. A threshold is a number, or an entity id whose state supplies it;
|
||||
// an unspecified threshold imposes no constraint and an unresolvable one fails.
|
||||
// Shared by the numeric_state condition and trigger, which match identically.
|
||||
export const matchesNumericState = (
|
||||
entityID: string,
|
||||
state: ConditionState,
|
||||
config: NumericStateBase,
|
||||
templateRenderer: TemplateRenderer,
|
||||
): boolean => {
|
||||
const hass = state.hass;
|
||||
if (!hass) {
|
||||
return false;
|
||||
}
|
||||
const value = readNumericStateValue(entityID, state, config, templateRenderer);
|
||||
if (value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const checkBound = (
|
||||
compare: (value: number, bound: number) => boolean,
|
||||
threshold?: number | string,
|
||||
): boolean => {
|
||||
if (threshold === undefined) {
|
||||
return true;
|
||||
}
|
||||
const bound =
|
||||
typeof threshold === 'number'
|
||||
? threshold
|
||||
: Number(hass.states?.[threshold]?.state);
|
||||
return Number.isFinite(bound) && compare(value, bound);
|
||||
};
|
||||
|
||||
return (
|
||||
checkBound((v, bound) => v > bound, config.above) &&
|
||||
checkBound((v, bound) => v < bound, config.below)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { TemplateRenderer } from '../../card-controller/templates';
|
||||
import { TimePeriod } from '../../config/schema/common/time-period';
|
||||
import { isRecord } from '../../utils/basic';
|
||||
import { ConditionState } from '../conditions/types';
|
||||
|
||||
// Parses a Home Assistant time-period value (a condition/trigger `for:`) to
|
||||
// seconds, matching HA's `cv.time_period`:
|
||||
// - a number, or a bare numeric string, is a count of seconds;
|
||||
// - a colon string is `HH:MM` or `HH:MM:SS` — HA reads TWO parts as
|
||||
// hours:minutes (not minutes:seconds);
|
||||
// - a `{days, hours, minutes, seconds, milliseconds}` dict (each field a
|
||||
// number or a numeric string, e.g. once a template field has been rendered).
|
||||
// Accepts `unknown` so a freshly-rendered value can be parsed directly; returns
|
||||
// null when unparseable or negative (HA `for:` requires a positive period).
|
||||
const parseTimePeriodToSeconds = (value: unknown): number | null => {
|
||||
const num = (field: unknown): number => Number(field ?? 0);
|
||||
let seconds: number;
|
||||
if (typeof value === 'number') {
|
||||
seconds = value;
|
||||
} else if (typeof value === 'string') {
|
||||
if (value.includes(':')) {
|
||||
const parts = value.split(':');
|
||||
if (parts.length < 2 || parts.length > 3 || parts.some((part) => !part.trim())) {
|
||||
return null;
|
||||
}
|
||||
const [hours, minutes, secs = 0] = parts.map(Number);
|
||||
seconds = hours * 3600 + minutes * 60 + secs;
|
||||
} else if (!value.trim()) {
|
||||
return null;
|
||||
} else {
|
||||
seconds = Number(value);
|
||||
}
|
||||
} else if (isRecord(value)) {
|
||||
seconds =
|
||||
num(value.days) * 86400 +
|
||||
num(value.hours) * 3600 +
|
||||
num(value.minutes) * 60 +
|
||||
num(value.seconds) +
|
||||
num(value.milliseconds) / 1000;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
|
||||
};
|
||||
|
||||
// Renders any templates within a `for:` time period (HA's
|
||||
// `cv.positive_time_period_template`) against the current state, then parses it
|
||||
// to seconds. The whole value or any dict field may be a template; a
|
||||
// template-free value renders to itself. Without `hass` the value cannot be
|
||||
// rendered, so it is parsed as-is (a literal duration still works; a template
|
||||
// yields null).
|
||||
export const renderTimePeriodToSeconds = (
|
||||
templateRenderer: TemplateRenderer,
|
||||
value: TimePeriod,
|
||||
conditionState?: ConditionState,
|
||||
): number | null => {
|
||||
if (!conditionState?.hass) {
|
||||
return parseTimePeriodToSeconds(value);
|
||||
}
|
||||
return parseTimePeriodToSeconds(
|
||||
templateRenderer.renderRecursively(conditionState.hass, value, { conditionState }),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { TemplateRenderer } from '../../card-controller/templates';
|
||||
import { Condition } from '../../config/schema/condition-trigger/conditions/types';
|
||||
import { isEnabled } from '../common/is-enabled';
|
||||
import {
|
||||
ConditionEvaluator,
|
||||
ExternalInvalidationUnsubscribeCallback,
|
||||
} from './conditions/types';
|
||||
import { createConditionEvaluator } from './factory';
|
||||
import {
|
||||
ConditionsEvaluationResult,
|
||||
ConditionsListener,
|
||||
ConditionsManagerReadonlyInterface,
|
||||
ConditionStateChange,
|
||||
ConditionStateManagerReadonlyInterface,
|
||||
} from './types';
|
||||
|
||||
// A condition evaluator paired with its config, so `enabled` can be re-checked
|
||||
// against the config each time conditions are evaluated.
|
||||
interface ManagedCondition {
|
||||
config: Condition;
|
||||
evaluator: ConditionEvaluator;
|
||||
}
|
||||
|
||||
/**
|
||||
* A class to evaluate an array of conditions, and notify listeners when the
|
||||
* evaluation result changes.
|
||||
*/
|
||||
export class ConditionsManager implements ConditionsManagerReadonlyInterface {
|
||||
private _stateManager: ConditionStateManagerReadonlyInterface | null;
|
||||
private _templateRenderer = new TemplateRenderer();
|
||||
private _conditions: ManagedCondition[];
|
||||
|
||||
private _listeners: ConditionsListener[] = [];
|
||||
private _evaluation: ConditionsEvaluationResult = { result: false };
|
||||
private _unsubscribeCallbacks: ExternalInvalidationUnsubscribeCallback[] = [];
|
||||
|
||||
constructor(
|
||||
conditions: Condition[],
|
||||
stateManager?: ConditionStateManagerReadonlyInterface | null,
|
||||
) {
|
||||
const context = { templateRenderer: this._templateRenderer };
|
||||
this._conditions = conditions.map((config) => ({
|
||||
config,
|
||||
evaluator: createConditionEvaluator(config, context),
|
||||
}));
|
||||
|
||||
this._stateManager = stateManager ?? null;
|
||||
|
||||
// Subscribe to evaluators' external invalidation sources, including those
|
||||
// nested inside composites, so a change there triggers a re-evaluation
|
||||
// (this is not necessary for most conditions since they are purely based on
|
||||
// ConditionState, but there are exceptions, e.g. screen).
|
||||
this._conditions.forEach(({ evaluator }) =>
|
||||
(evaluator.externalSources ?? []).forEach((source) =>
|
||||
this._unsubscribeCallbacks.push(source.subscribe(() => this._evaluate())),
|
||||
),
|
||||
);
|
||||
|
||||
// Do an initial condition evaluation, but without calling listeners.
|
||||
this._evaluate({ callListeners: false });
|
||||
|
||||
this._stateManager?.addListener(this._stateManagerHandler);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._stateManager?.removeListener(this._stateManagerHandler);
|
||||
|
||||
this._listeners.forEach((l) => this.removeListener(l));
|
||||
|
||||
this._unsubscribeCallbacks.forEach((unsubscribe) => unsubscribe());
|
||||
this._unsubscribeCallbacks = [];
|
||||
this._conditions = [];
|
||||
}
|
||||
|
||||
public addListener(listener: ConditionsListener): void {
|
||||
if (!this._listeners.includes(listener)) {
|
||||
this._listeners.push(listener);
|
||||
}
|
||||
}
|
||||
|
||||
public removeListener(listener: ConditionsListener): void {
|
||||
this._listeners = this._listeners.filter((l) => l !== listener);
|
||||
}
|
||||
|
||||
public getEvaluation(): ConditionsEvaluationResult {
|
||||
return this._evaluation;
|
||||
}
|
||||
|
||||
private _stateManagerHandler = (stateChange: ConditionStateChange): void => {
|
||||
this._evaluate({ stateChange });
|
||||
};
|
||||
|
||||
private _evaluate(options?: {
|
||||
stateChange?: ConditionStateChange;
|
||||
callListeners?: boolean;
|
||||
}): void {
|
||||
const state = options?.stateChange?.new ?? this._stateManager?.getState();
|
||||
|
||||
let result = true;
|
||||
|
||||
for (const { config, evaluator } of this._conditions) {
|
||||
if (!isEnabled(this._templateRenderer, config.enabled, state)) {
|
||||
continue;
|
||||
}
|
||||
if (!evaluator.evaluate(state, options?.stateChange?.old).result) {
|
||||
result = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const evaluation: ConditionsEvaluationResult = { result };
|
||||
|
||||
if (result !== this._evaluation.result) {
|
||||
this._evaluation = evaluation;
|
||||
if (options?.callListeners ?? true) {
|
||||
this._listeners.forEach((listener) =>
|
||||
listener(this._evaluation, options?.stateChange),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { CompositeConditionEvaluator } from './composite';
|
||||
|
||||
export class AndConditionEvaluator extends CompositeConditionEvaluator {
|
||||
public evaluate(
|
||||
newState?: ConditionState,
|
||||
oldState?: ConditionState,
|
||||
): ConditionsEvaluationResult {
|
||||
for (const child of this._children) {
|
||||
if (!child.evaluate(newState, oldState).result) {
|
||||
return { result: false };
|
||||
}
|
||||
}
|
||||
return { result: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { CallBase } from '../../../config/schema/condition-trigger/common/call';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class CallConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: CallBase;
|
||||
|
||||
constructor(condition: CallBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return {
|
||||
result: this._condition.call === (newState?.call ?? false),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CameraBase } from '../../../config/schema/condition-trigger/common/camera';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class CameraConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: CameraBase;
|
||||
|
||||
constructor(condition: CameraBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
const camera = newState?.camera;
|
||||
const cameras = this._condition.cameras;
|
||||
if (cameras === undefined) {
|
||||
// Omitted: a camera is selected.
|
||||
return { result: !!camera };
|
||||
}
|
||||
if (cameras.length === 0) {
|
||||
// `[]`: no camera is selected.
|
||||
return { result: !camera };
|
||||
}
|
||||
// A list: the selected camera is one of these.
|
||||
return { result: !!camera && cameras.includes(camera) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator, ExternalInvalidationSource } from './types';
|
||||
|
||||
/**
|
||||
* Base class for the `or`/`and`/`not` composites: each holds child evaluators
|
||||
* and unions their external invalidation sources. Subclasses provide
|
||||
* `evaluate`.
|
||||
*/
|
||||
export abstract class CompositeConditionEvaluator implements ConditionEvaluator {
|
||||
protected _children: ConditionEvaluator[];
|
||||
|
||||
constructor(children: ConditionEvaluator[]) {
|
||||
this._children = children;
|
||||
}
|
||||
|
||||
public abstract evaluate(
|
||||
newState?: ConditionState,
|
||||
oldState?: ConditionState,
|
||||
): ConditionsEvaluationResult;
|
||||
|
||||
public get externalSources(): ExternalInvalidationSource[] {
|
||||
return this._children.flatMap((child) => child.externalSources ?? []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { DisplayModeBase } from '../../../config/schema/condition-trigger/common/display-mode';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class DisplayModeConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: DisplayModeBase;
|
||||
|
||||
constructor(condition: DisplayModeBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return {
|
||||
result:
|
||||
!!newState?.displayMode && this._condition.display_mode === newState.displayMode,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ExpandBase } from '../../../config/schema/condition-trigger/common/expand';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class ExpandConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ExpandBase;
|
||||
|
||||
constructor(condition: ExpandBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return {
|
||||
result:
|
||||
newState?.expand !== undefined && this._condition.expand === newState.expand,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { FullscreenBase } from '../../../config/schema/condition-trigger/common/fullscreen';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class FullscreenConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: FullscreenBase;
|
||||
|
||||
constructor(condition: FullscreenBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return {
|
||||
result:
|
||||
newState?.fullscreen !== undefined &&
|
||||
this._condition.fullscreen === newState.fullscreen,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class InitializedConditionEvaluator implements ConditionEvaluator {
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return { result: !!newState?.initialized };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { InteractionBase } from '../../../config/schema/condition-trigger/common/interaction';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class InteractionConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: InteractionBase;
|
||||
|
||||
constructor(condition: InteractionBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return {
|
||||
result:
|
||||
newState?.interaction !== undefined &&
|
||||
this._condition.interaction === newState.interaction,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Whether a rendered template result counts as true for a CONDITION. HA's
|
||||
// `condition.py` `async_template` does `value.lower() == "true"`, so only
|
||||
// `true` (case-insensitive) passes -- `yes`/`on`/`1` do NOT (unlike a trigger).
|
||||
// The card's renderer returns native types, so the boolean a comparison
|
||||
// template (e.g. `{{ a == b }}`) produces stringifies to "true" and also
|
||||
// passes.
|
||||
export const isTemplateTrue = (value: unknown): boolean =>
|
||||
String(value).toLowerCase() === 'true';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { KeyBase } from '../../../config/schema/condition-trigger/common/key';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class KeyConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: KeyBase;
|
||||
|
||||
constructor(condition: KeyBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
const condition = this._condition;
|
||||
|
||||
// The condition schema requires `key`; the undefined check only guards the
|
||||
// shared base type, on which it is declared optional.
|
||||
if (condition.key === undefined || !newState?.keys?.[condition.key]) {
|
||||
return { result: false };
|
||||
}
|
||||
const pressed = newState.keys[condition.key];
|
||||
return {
|
||||
result:
|
||||
(condition.state ?? 'down') === pressed.state &&
|
||||
(condition.ctrl === undefined || condition.ctrl === !!pressed.ctrl) &&
|
||||
(condition.alt === undefined || condition.alt === !!pressed.alt) &&
|
||||
(condition.meta === undefined || condition.meta === !!pressed.meta) &&
|
||||
(condition.shift === undefined || condition.shift === !!pressed.shift),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MediaLoadedBase } from '../../../config/schema/condition-trigger/common/media-loaded';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class MediaLoadedConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: MediaLoadedBase;
|
||||
|
||||
constructor(condition: MediaLoadedBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return {
|
||||
result:
|
||||
newState?.mediaLoadedInfo !== undefined &&
|
||||
this._condition.media_loaded === !!newState.mediaLoadedInfo,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MicrophoneBase } from '../../../config/schema/condition-trigger/common/microphone';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class MicrophoneConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: MicrophoneBase;
|
||||
|
||||
constructor(condition: MicrophoneBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return {
|
||||
result: newState?.microphone?.muted === this._condition.muted,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { CompositeConditionEvaluator } from './composite';
|
||||
|
||||
export class NotConditionEvaluator extends CompositeConditionEvaluator {
|
||||
// "Not" is an inverted `or` (NOR): true when no child matches.
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { CompositeConditionEvaluator } from './composite';
|
||||
|
||||
export class OrConditionEvaluator extends CompositeConditionEvaluator {
|
||||
public evaluate(
|
||||
newState?: ConditionState,
|
||||
oldState?: ConditionState,
|
||||
): ConditionsEvaluationResult {
|
||||
for (const child of this._children) {
|
||||
if (child.evaluate(newState, oldState).result) {
|
||||
return { result: true };
|
||||
}
|
||||
}
|
||||
return { result: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ScreenBase } from '../../../config/schema/condition-trigger/common/screen';
|
||||
import { MediaQueryWatcher } from '../../common/media-query-watcher';
|
||||
import { ConditionsEvaluationResult } from '../types';
|
||||
import { ConditionEvaluator, ExternalInvalidationSource } from './types';
|
||||
|
||||
export class ScreenConditionEvaluator implements ConditionEvaluator {
|
||||
private _watcher: MediaQueryWatcher | null;
|
||||
|
||||
constructor(condition: ScreenBase) {
|
||||
this._watcher = condition.media_query
|
||||
? new MediaQueryWatcher(condition.media_query)
|
||||
: null;
|
||||
}
|
||||
|
||||
public evaluate(): ConditionsEvaluationResult {
|
||||
return { result: this._watcher?.matches() ?? false };
|
||||
}
|
||||
|
||||
public get externalSources(): ExternalInvalidationSource[] {
|
||||
return this._watcher ? [this._watcher] : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { arrayify } from '../../../utils/basic';
|
||||
import { renderTimePeriodToSeconds } from '../../common/time-period';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './types';
|
||||
|
||||
// Resolve each expected value that names an entity present in `hass` to that
|
||||
// entity's current state, accepting either the literal or the resolved value
|
||||
// (i.e. HA's Lovelace state condition will resolve "state: input_boolean.foo"
|
||||
// to "state: on" when input_boolean.foo is on).
|
||||
const resolveExpectedStates = (
|
||||
values: string | string[],
|
||||
state?: ConditionState,
|
||||
): string[] =>
|
||||
// Cannot use `arrayify` as an empty-string expected value is a real value.
|
||||
(Array.isArray(values) ? values : [values]).flatMap((value) => {
|
||||
const resolved = state?.hass?.states?.[value]?.state;
|
||||
return resolved !== undefined ? [value, resolved] : [value];
|
||||
});
|
||||
|
||||
export class StateConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ConditionOfType<'state'>;
|
||||
private _context: EvaluatorContext;
|
||||
|
||||
constructor(condition: ConditionOfType<'state'>, context: EvaluatorContext) {
|
||||
this._condition = condition;
|
||||
this._context = context;
|
||||
}
|
||||
|
||||
public evaluate(
|
||||
newState?: ConditionState,
|
||||
oldState?: ConditionState,
|
||||
): ConditionsEvaluationResult {
|
||||
const condition = this._condition;
|
||||
|
||||
// `entity` is canonical; `entity_id` is the accepted automation-dialect alias.
|
||||
// Either may be a list; with multiple entities all must match (HA's `match: all`).
|
||||
const entityIDs = arrayify(condition.entity ?? condition.entity_id);
|
||||
if (!entityIDs.length) {
|
||||
return { result: false };
|
||||
}
|
||||
|
||||
// The compared value is the attribute when `attribute` is set, else the state.
|
||||
const readValue = (entityID: string, state?: ConditionState): string | null => {
|
||||
const stateObj = state?.hass?.states?.[entityID];
|
||||
if (!stateObj) {
|
||||
return null;
|
||||
}
|
||||
if (condition.attribute) {
|
||||
const value = stateObj.attributes?.[condition.attribute];
|
||||
return value === undefined || value === null ? null : String(value);
|
||||
}
|
||||
return stateObj.state;
|
||||
};
|
||||
|
||||
const matchesEntity = (entityID: string): boolean => {
|
||||
const fromValue = readValue(entityID, oldState);
|
||||
const toValue = readValue(entityID, newState);
|
||||
|
||||
let result: boolean;
|
||||
if (condition.state === undefined && condition.state_not === undefined) {
|
||||
// With neither `state` nor `state_not`, match any change of value.
|
||||
result = toValue !== fromValue;
|
||||
} else if (toValue === null) {
|
||||
// A missing entity or attribute cannot match; an empty-string state is
|
||||
// a real value, handled in the comparison below.
|
||||
result = false;
|
||||
} else {
|
||||
result =
|
||||
(condition.state === undefined ||
|
||||
resolveExpectedStates(condition.state, newState).includes(toValue)) &&
|
||||
(condition.state_not === undefined ||
|
||||
!resolveExpectedStates(condition.state_not, newState).includes(toValue));
|
||||
}
|
||||
|
||||
// `for`: the match must have been held for at least the given duration.
|
||||
// Evaluated against `last_changed` at evaluation time (correct for the
|
||||
// point-in-time / ongoing-condition use).
|
||||
if (result && condition.for !== undefined) {
|
||||
const forSeconds = renderTimePeriodToSeconds(
|
||||
this._context.templateRenderer,
|
||||
condition.for,
|
||||
newState,
|
||||
);
|
||||
const lastChanged = newState?.hass?.states?.[entityID]?.last_changed;
|
||||
if (forSeconds === null || !lastChanged) {
|
||||
result = false;
|
||||
} else {
|
||||
const heldSeconds =
|
||||
(new Date().getTime() - new Date(lastChanged).getTime()) / 1000;
|
||||
result = heldSeconds >= forSeconds;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// `match: any` requires one entity to match; `all` (the default) requires all.
|
||||
const result =
|
||||
condition.match === 'any'
|
||||
? entityIDs.some(matchesEntity)
|
||||
: entityIDs.every(matchesEntity);
|
||||
|
||||
return { result };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { isTemplateTrue } from './is-template-true';
|
||||
import { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './types';
|
||||
|
||||
export class TemplateConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ConditionOfType<'template'>;
|
||||
private _context: EvaluatorContext;
|
||||
|
||||
constructor(condition: ConditionOfType<'template'>, context: EvaluatorContext) {
|
||||
this._condition = condition;
|
||||
this._context = context;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return {
|
||||
result:
|
||||
!!newState?.hass &&
|
||||
isTemplateTrue(
|
||||
this._context.templateRenderer.renderRecursively(
|
||||
newState.hass,
|
||||
this._condition.value_template,
|
||||
{ conditionState: newState },
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { TriggeredBase } from '../../../config/schema/condition-trigger/common/triggered';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class TriggeredConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: TriggeredBase;
|
||||
|
||||
constructor(condition: TriggeredBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
const active = newState?.triggered;
|
||||
const cameraIDs = this._condition.triggered;
|
||||
const count = active?.size ?? 0;
|
||||
|
||||
let result: boolean;
|
||||
if (cameraIDs === undefined) {
|
||||
// Omitted: any camera is triggered.
|
||||
result = count > 0;
|
||||
} else if (cameraIDs.length === 0) {
|
||||
// `[]`: no camera is triggered.
|
||||
result = count === 0;
|
||||
} else {
|
||||
// A list: one of the named cameras is among those triggered.
|
||||
result = !!active && cameraIDs.some((cameraID) => active.has(cameraID));
|
||||
}
|
||||
return { result };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TemplateRenderer } from '../../../card-controller/templates';
|
||||
import { Condition } from '../../../config/schema/condition-trigger/conditions/types';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
|
||||
export type ExternalInvalidationUnsubscribeCallback = () => void;
|
||||
|
||||
// A source of change outside the card's `ConditionState` that can invalidate a
|
||||
// condition's result (currently only `screen`, via `matchMedia`). A condition
|
||||
// declares its sources so a reactive consumer knows what to watch; a pull
|
||||
// consumer ignores them.
|
||||
export interface ExternalInvalidationSource {
|
||||
subscribe(callback: () => void): ExternalInvalidationUnsubscribeCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single condition, constructed once with its configuration and evaluated
|
||||
* repeatedly against incoming state.
|
||||
*/
|
||||
export interface ConditionEvaluator {
|
||||
evaluate(
|
||||
newState?: ConditionState,
|
||||
oldState?: ConditionState,
|
||||
): ConditionsEvaluationResult;
|
||||
|
||||
// Sources of change outside `ConditionState` that can invalidate this
|
||||
// condition's result (currently only `screen`, via `matchMedia`). A reactive
|
||||
// consumer subscribes to them to know when to re-evaluate; a pull consumer
|
||||
// ignores them.
|
||||
externalSources?: ExternalInvalidationSource[];
|
||||
}
|
||||
|
||||
export interface EvaluatorContext {
|
||||
templateRenderer: TemplateRenderer;
|
||||
}
|
||||
|
||||
// The condition union member(s) carrying a given discriminator literal.
|
||||
export type ConditionOfType<T extends string> = Extract<Condition, { condition?: T }>;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { isBeingCasted } from '../../../utils/casting';
|
||||
import { isCompanionApp } from '../../../utils/companion';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator, ConditionOfType } from './types';
|
||||
|
||||
export class UserAgentConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ConditionOfType<'user_agent'>;
|
||||
|
||||
constructor(condition: ConditionOfType<'user_agent'>) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
const condition = this._condition;
|
||||
return {
|
||||
result:
|
||||
!!newState?.userAgent &&
|
||||
(!condition.user_agent || condition.user_agent === newState.userAgent) &&
|
||||
(condition.casting === undefined ||
|
||||
condition.casting === isBeingCasted(newState.userAgent)) &&
|
||||
(condition.companion === undefined ||
|
||||
condition.companion === isCompanionApp(newState.userAgent)) &&
|
||||
(condition.user_agent_re === undefined ||
|
||||
new RegExp(condition.user_agent_re).test(newState.userAgent)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator, ConditionOfType } from './types';
|
||||
|
||||
export class UserConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ConditionOfType<'user'>;
|
||||
|
||||
constructor(condition: ConditionOfType<'user'>) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return {
|
||||
result:
|
||||
!!newState?.hass?.user &&
|
||||
!!this._condition.users?.includes(newState.hass.user.id),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ViewBase } from '../../../config/schema/condition-trigger/common/view';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class ViewConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ViewBase;
|
||||
|
||||
constructor(condition: ViewBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
const view = newState?.view;
|
||||
return {
|
||||
// The condition schema requires `views`; the optional access only guards
|
||||
// the shared base type, on which it is declared optional.
|
||||
result: !!view && !!this._condition.views?.includes(view),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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 { DisplayModeConditionEvaluator } from './conditions/display-mode';
|
||||
import { ExpandConditionEvaluator } from './conditions/expand';
|
||||
import { FullscreenConditionEvaluator } from './conditions/fullscreen';
|
||||
import { InitializedConditionEvaluator } from './conditions/initialized';
|
||||
import { InteractionConditionEvaluator } from './conditions/interaction';
|
||||
import { KeyConditionEvaluator } from './conditions/key';
|
||||
import { MediaLoadedConditionEvaluator } from './conditions/media-loaded';
|
||||
import { MicrophoneConditionEvaluator } from './conditions/microphone';
|
||||
import { NotConditionEvaluator } from './conditions/not';
|
||||
import { NumericStateConditionEvaluator } from './conditions/numeric-state';
|
||||
import { OrConditionEvaluator } from './conditions/or';
|
||||
import { ScreenConditionEvaluator } from './conditions/screen';
|
||||
import { StateConditionEvaluator } from './conditions/state';
|
||||
import { TemplateConditionEvaluator } from './conditions/template';
|
||||
import { TriggeredConditionEvaluator } from './conditions/triggered';
|
||||
import { ConditionEvaluator, EvaluatorContext } from './conditions/types';
|
||||
import { UserConditionEvaluator } from './conditions/user';
|
||||
import { UserAgentConditionEvaluator } from './conditions/user-agent';
|
||||
import { ViewConditionEvaluator } from './conditions/view';
|
||||
|
||||
export const createConditionEvaluator = (
|
||||
condition: Condition,
|
||||
context: EvaluatorContext,
|
||||
): ConditionEvaluator => {
|
||||
switch (condition.condition) {
|
||||
case undefined:
|
||||
case 'state':
|
||||
return new StateConditionEvaluator(condition, context);
|
||||
case 'view':
|
||||
return new ViewConditionEvaluator(condition);
|
||||
case 'fullscreen':
|
||||
return new FullscreenConditionEvaluator(condition);
|
||||
case 'expand':
|
||||
return new ExpandConditionEvaluator(condition);
|
||||
case 'camera':
|
||||
return new CameraConditionEvaluator(condition);
|
||||
case 'numeric_state':
|
||||
return new NumericStateConditionEvaluator(condition, context);
|
||||
case 'user':
|
||||
return new UserConditionEvaluator(condition);
|
||||
case 'media_loaded':
|
||||
return new MediaLoadedConditionEvaluator(condition);
|
||||
case 'screen':
|
||||
return new ScreenConditionEvaluator(condition);
|
||||
case 'display_mode':
|
||||
return new DisplayModeConditionEvaluator(condition);
|
||||
case 'triggered':
|
||||
return new TriggeredConditionEvaluator(condition);
|
||||
case 'interaction':
|
||||
return new InteractionConditionEvaluator(condition);
|
||||
case 'microphone':
|
||||
return new MicrophoneConditionEvaluator(condition);
|
||||
case 'call':
|
||||
return new CallConditionEvaluator(condition);
|
||||
case 'key':
|
||||
return new KeyConditionEvaluator(condition);
|
||||
case 'user_agent':
|
||||
return new UserAgentConditionEvaluator(condition);
|
||||
case 'initialized':
|
||||
return new InitializedConditionEvaluator();
|
||||
case 'template':
|
||||
return new TemplateConditionEvaluator(condition, context);
|
||||
case 'or':
|
||||
return new OrConditionEvaluator(
|
||||
condition.conditions.map((child) => createConditionEvaluator(child, context)),
|
||||
);
|
||||
case 'and':
|
||||
return new AndConditionEvaluator(
|
||||
condition.conditions.map((child) => createConditionEvaluator(child, context)),
|
||||
);
|
||||
case 'not':
|
||||
return new NotConditionEvaluator(
|
||||
condition.conditions.map((child) => createConditionEvaluator(child, context)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ConditionStateManager } from './state-manager';
|
||||
|
||||
export class ConditionStateManagerGetEvent extends Event {
|
||||
public conditionStateManager?: ConditionStateManager;
|
||||
|
||||
constructor(eventInitDict?: EventInit) {
|
||||
super('advanced-camera-card:condition-state-manager:get', eventInitDict);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the main ConditionStateManager via an event.
|
||||
* @returns The ConditionStateManager or null if not found.
|
||||
*/
|
||||
|
||||
export function getConditionStateManagerViaEvent(
|
||||
element: HTMLElement,
|
||||
): ConditionStateManager | null {
|
||||
const getEvent = new ConditionStateManagerGetEvent({
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
});
|
||||
|
||||
/* Special note on what's going on here:
|
||||
*
|
||||
* Some parts of the card (e.g. <advanced-camera-card-elements>) may have arbitrary
|
||||
* complexity and layers (that this card doesn't control) between that master
|
||||
* element and the element that needs to evaluate the condition. In these
|
||||
* cases there's no clean way to pass state from the rest of card down through
|
||||
* these layers. Instead, an event is dispatched as a "request for evaluation"
|
||||
* (ConditionEvaluateRequestEvent) upwards which is caught by the outer card
|
||||
* and the evaluation result is added to the event object. Because event
|
||||
* propagation is handled synchronously, the result will be added to the event
|
||||
* before the flow proceeds.
|
||||
*/
|
||||
element.dispatchEvent(getEvent);
|
||||
return getEvent.conditionStateManager ?? null;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { SerialRunner } from '../../utils/concurrency/serial-runner';
|
||||
import {
|
||||
ConditionState,
|
||||
ConditionStateChange,
|
||||
ConditionStateListener,
|
||||
ConditionStateManagerReadonlyInterface,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* A class to manage state used in the evaluation of conditions.
|
||||
*/
|
||||
export class ConditionStateManager implements ConditionStateManagerReadonlyInterface {
|
||||
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);
|
||||
}
|
||||
|
||||
public removeListener(listener?: ConditionStateListener): void {
|
||||
this._listeners = this._listeners.filter((l) => l !== listener);
|
||||
}
|
||||
|
||||
public getState(): ConditionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
const oldState = this._state;
|
||||
this._state = {
|
||||
...oldState,
|
||||
...changeState,
|
||||
};
|
||||
this._callListeners({ old: oldState, change: changeState, new: this._state });
|
||||
return true;
|
||||
}
|
||||
|
||||
private _calculateTrueChange(change: ConditionState): ConditionState {
|
||||
const changeState: ConditionState = {};
|
||||
|
||||
for (const key of Object.keys(change)) {
|
||||
if (!isEqual(change[key], this._state[key])) {
|
||||
changeState[key] = change[key];
|
||||
}
|
||||
}
|
||||
|
||||
return changeState;
|
||||
}
|
||||
|
||||
private _callListeners = (stateChange: ConditionStateChange): void => {
|
||||
this._listeners.forEach((listener) => listener(stateChange));
|
||||
};
|
||||
}
|
||||
@@ -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<string>;
|
||||
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;
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { TemplateRenderer } from '../../card-controller/templates';
|
||||
import { Trigger } from '../../config/schema/condition-trigger/triggers/types';
|
||||
import { isEnabled } from '../common/is-enabled';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
|
||||
import { createTriggerEvaluator } from './factory';
|
||||
import {
|
||||
TriggerCallback,
|
||||
TriggerEvaluator,
|
||||
TriggerEvaluatorContext,
|
||||
} from './triggers/types';
|
||||
import { TriggerData } from './types';
|
||||
|
||||
// A trigger evaluator paired with its config, so `enabled` can be re-checked
|
||||
// against the config each time the evaluator triggers.
|
||||
interface ManagedTrigger {
|
||||
config: Trigger;
|
||||
evaluator: TriggerEvaluator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates an array of triggers (e.g. for one automation) and notifies
|
||||
* listeners whenever ANY of them triggers (the top-level triggers list is an
|
||||
* implicit OR). This is the push-based sibling of `ConditionsManager`.
|
||||
*/
|
||||
export class TriggersManager {
|
||||
private _context: TriggerEvaluatorContext;
|
||||
private _triggers: ManagedTrigger[];
|
||||
private _listeners: TriggerCallback[] = [];
|
||||
|
||||
constructor(
|
||||
triggers: Trigger[],
|
||||
stateManager: ConditionStateManagerReadonlyInterface,
|
||||
) {
|
||||
this._context = { stateManager, templateRenderer: new TemplateRenderer() };
|
||||
this._triggers = triggers.map((config) => ({
|
||||
config,
|
||||
evaluator: createTriggerEvaluator(config, this._context),
|
||||
}));
|
||||
|
||||
// `enabled` is a live per-trigger gate (re-evaluated each time), UNLIKE
|
||||
// HA's once-at-attach: a deliberate deviation to allow dynamic triggering.
|
||||
this._triggers.forEach(({ config, evaluator }) =>
|
||||
evaluator.subscribe((data) => {
|
||||
if (
|
||||
isEnabled(
|
||||
this._context.templateRenderer,
|
||||
config.enabled,
|
||||
this._context.stateManager.getState(),
|
||||
// Fail closed: with no hass the `enabled` template cannot be
|
||||
// evaluated, so the trigger does not fire.
|
||||
false,
|
||||
)
|
||||
) {
|
||||
this._callListeners(data);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._triggers.forEach(({ evaluator }) => evaluator.destroy());
|
||||
this._triggers = [];
|
||||
this._listeners = [];
|
||||
}
|
||||
|
||||
public addListener(listener: TriggerCallback): void {
|
||||
if (!this._listeners.includes(listener)) {
|
||||
this._listeners.push(listener);
|
||||
}
|
||||
}
|
||||
|
||||
public removeListener(listener: TriggerCallback): void {
|
||||
this._listeners = this._listeners.filter((l) => l !== listener);
|
||||
}
|
||||
|
||||
private _callListeners = (data: TriggerData): void => {
|
||||
this._listeners.forEach((listener) => listener(data));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
||||
import { TriggerOfType } from './types';
|
||||
|
||||
export class CallTrigger extends ConditionStateTriggerBase<TriggerOfType<'call'>> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.call ?? false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
||||
import { TriggerOfType } from './types';
|
||||
|
||||
// Triggers when the selected camera changes: to one of `cameras` if listed, to
|
||||
// no camera if `cameras` is `[]`, or on any change if `cameras` is omitted.
|
||||
export class CameraTrigger extends ConditionStateTriggerBase<TriggerOfType<'camera'>> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.camera;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Trigger } from '../../../config/schema/condition-trigger/triggers/types';
|
||||
import { ConditionEvaluator } from '../../conditions/conditions/types';
|
||||
import { createConditionEvaluatorForTrigger } from '../../conditions/factory';
|
||||
import { ConditionState, ConditionStateChange } from '../../conditions/types';
|
||||
import { buildCardTriggerData } from '../build-trigger-data';
|
||||
import { TriggerCallback, TriggerEvaluator, TriggerEvaluatorContext } from './types';
|
||||
|
||||
// A trigger driven by `ConditionState` changes: subscribe to the state manager,
|
||||
// fire when the watched value (`_getValue`) changes and the new state passes
|
||||
// the trigger's condition, and emit the `acc` payload. The condition is the
|
||||
// matching condition reused as a point-in-time predicate -- so a trigger and
|
||||
// its condition share one definition of meaning. A trigger with no value (any
|
||||
// change), or no matching condition (`config`), has no condition to pass.
|
||||
export abstract class ConditionStateTriggerBase<T extends Trigger>
|
||||
implements TriggerEvaluator
|
||||
{
|
||||
protected _trigger: T;
|
||||
protected _context: TriggerEvaluatorContext;
|
||||
|
||||
private _callback: TriggerCallback | null = null;
|
||||
private _condition: ConditionEvaluator | null;
|
||||
|
||||
constructor(trigger: T, context: TriggerEvaluatorContext) {
|
||||
this._trigger = trigger;
|
||||
this._context = context;
|
||||
|
||||
// The condition the trigger checks each change against.
|
||||
this._condition = createConditionEvaluatorForTrigger(trigger);
|
||||
}
|
||||
|
||||
public subscribe(callback: TriggerCallback): void {
|
||||
this._callback = callback;
|
||||
this._context.stateManager.addListener(this._handler);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._context.stateManager.removeListener(this._handler);
|
||||
this._callback = null;
|
||||
}
|
||||
|
||||
private _handler = (change: ConditionStateChange): void => {
|
||||
if (isEqual(this._getValue(change.old), this._getValue(change.new))) {
|
||||
return;
|
||||
}
|
||||
if (this._condition && !this._condition.evaluate(change.new).result) {
|
||||
return;
|
||||
}
|
||||
this._callback?.(buildCardTriggerData(this._trigger.trigger, change));
|
||||
};
|
||||
|
||||
// The slice of state this trigger watches; it fires only when this changes.
|
||||
protected abstract _getValue(state: ConditionState): unknown;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getConfigValue } from '../../../config/management';
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
||||
import { TriggerOfType } from './types';
|
||||
|
||||
// Triggers when the card configuration changes. With `paths`, only a change to
|
||||
// one of those config paths triggers; without `paths`, any config change does.
|
||||
// `config` is trigger-only (it has no matching condition), so the watched value
|
||||
// is the whole of its behavior.
|
||||
export class ConfigTrigger extends ConditionStateTriggerBase<TriggerOfType<'config'>> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
const config = state.config;
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
// The watched value: detecting a change in these path values *is* the
|
||||
// `paths` filter. Without `paths`, the whole config is watched.
|
||||
const paths = this._trigger.paths;
|
||||
return paths?.length ? paths.map((path) => getConfigValue(config, path)) : config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
||||
import { TriggerOfType } from './types';
|
||||
|
||||
export class DisplayModeTrigger extends ConditionStateTriggerBase<
|
||||
TriggerOfType<'display_mode'>
|
||||
> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.displayMode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { arrayify } from '../../../utils/basic';
|
||||
import { Timer } from '../../../utils/timer';
|
||||
import { renderTimePeriodToSeconds } from '../../common/time-period';
|
||||
import { ConditionStateChange } from '../../conditions/types';
|
||||
import {
|
||||
TriggerCallback,
|
||||
TriggerEvaluator,
|
||||
TriggerEvaluatorContext,
|
||||
TriggerOfType,
|
||||
} from './types';
|
||||
|
||||
// Shared scaffolding for the stock entity triggers (`state` and
|
||||
// `numeric_state`): per-`entity`/`entity_id` fan-out, the `for:` hold (one
|
||||
// Timer per entity), and the HA-faithful trigger payload. Subclasses implement
|
||||
// only the per-entity decision (`_processEntity`) and their `platform`.
|
||||
export abstract class EntityStateTriggerBase<
|
||||
T extends TriggerOfType<'state'> | TriggerOfType<'numeric_state'>,
|
||||
> implements TriggerEvaluator
|
||||
{
|
||||
protected _trigger: T;
|
||||
protected _context: TriggerEvaluatorContext;
|
||||
|
||||
private _callback: TriggerCallback | null = null;
|
||||
|
||||
// A `for:` hold per entity: a list can be holding several independently.
|
||||
private _forTimers = new Map<string, Timer>();
|
||||
|
||||
// The `trigger.platform` value reported in the trigger payload.
|
||||
protected abstract readonly _platform: string;
|
||||
|
||||
constructor(trigger: T, context: TriggerEvaluatorContext) {
|
||||
this._trigger = trigger;
|
||||
this._context = context;
|
||||
}
|
||||
|
||||
public subscribe(callback: TriggerCallback): void {
|
||||
this._callback = callback;
|
||||
this._onSubscribe();
|
||||
this._context.stateManager.addListener(this._stateChangehandler);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._context.stateManager.removeListener(this._stateChangehandler);
|
||||
this._forTimers.forEach((timer) => timer.stop());
|
||||
this._forTimers.clear();
|
||||
this._onDestroy();
|
||||
this._callback = null;
|
||||
}
|
||||
|
||||
protected abstract _processEntityChange(
|
||||
entityID: string,
|
||||
oldStateObj: HassEntity | undefined,
|
||||
newStateObj: HassEntity | undefined,
|
||||
): void;
|
||||
|
||||
protected _onSubscribe(): void {}
|
||||
protected _onDestroy(): void {}
|
||||
|
||||
protected _entityIDs(): string[] {
|
||||
return arrayify(this._trigger.entity_id ?? this._trigger.entity);
|
||||
}
|
||||
|
||||
// Cancel a pending `for:` hold for an entity that left its matching condition.
|
||||
protected _cancelForTimer(entityID: string): void {
|
||||
this._forTimers.get(entityID)?.stop();
|
||||
}
|
||||
|
||||
// Trigger immediately, or arm the `for:` hold so it triggers only after the
|
||||
// condition has held for the configured duration.
|
||||
protected _callTriggerOrHold(
|
||||
entityID: string,
|
||||
oldStateObj?: HassEntity,
|
||||
newStateObj?: HassEntity,
|
||||
): void {
|
||||
if (!this._trigger.for) {
|
||||
this._callTrigger(entityID, oldStateObj, newStateObj);
|
||||
return;
|
||||
}
|
||||
const seconds = renderTimePeriodToSeconds(
|
||||
this._context.templateRenderer,
|
||||
this._trigger.for,
|
||||
this._context.stateManager.getState(),
|
||||
);
|
||||
if (seconds !== null) {
|
||||
this._getForTimer(entityID).start(seconds, () =>
|
||||
this._callTrigger(entityID, oldStateObj, newStateObj),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _stateChangehandler = (change: ConditionStateChange): void => {
|
||||
for (const entityID of this._entityIDs()) {
|
||||
const oldStateObj = change.old.hass?.states?.[entityID];
|
||||
const newStateObj = change.new.hass?.states?.[entityID];
|
||||
|
||||
// Only entities whose state object actually changed are candidates.
|
||||
if (isEqual(oldStateObj, newStateObj)) {
|
||||
continue;
|
||||
}
|
||||
this._processEntityChange(entityID, oldStateObj, newStateObj);
|
||||
}
|
||||
};
|
||||
|
||||
private _getForTimer(entityID: string): Timer {
|
||||
let timer = this._forTimers.get(entityID);
|
||||
if (!timer) {
|
||||
timer = new Timer();
|
||||
this._forTimers.set(entityID, timer);
|
||||
}
|
||||
return timer;
|
||||
}
|
||||
|
||||
private _callTrigger(
|
||||
entityID: string,
|
||||
oldStateObj?: HassEntity,
|
||||
newStateObj?: HassEntity,
|
||||
): void {
|
||||
this._callback?.({
|
||||
platform: this._platform,
|
||||
entity_id: entityID,
|
||||
entity: entityID,
|
||||
...(oldStateObj && { from_state: oldStateObj }),
|
||||
...(newStateObj && { to_state: newStateObj }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
||||
import { TriggerOfType } from './types';
|
||||
|
||||
export class ExpandTrigger extends ConditionStateTriggerBase<TriggerOfType<'expand'>> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.expand;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
||||
import { TriggerOfType } from './types';
|
||||
|
||||
export class FullscreenTrigger extends ConditionStateTriggerBase<
|
||||
TriggerOfType<'fullscreen'>
|
||||
> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.fullscreen;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
||||
import { TriggerOfType } from './types';
|
||||
|
||||
export class InitializedTrigger extends ConditionStateTriggerBase<
|
||||
TriggerOfType<'initialized'>
|
||||
> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.initialized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
||||
import { TriggerOfType } from './types';
|
||||
|
||||
export class InteractionTrigger extends ConditionStateTriggerBase<
|
||||
TriggerOfType<'interaction'>
|
||||
> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.interaction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Whether a rendered template result counts as true for a TRIGGER. Mirrors HA's
|
||||
// `result_as_boolean`: a non-zero number, or `1`/`true`/`yes`/`on`/`enable`
|
||||
// (case-insensitive), is truthy -- more permissive than a condition.
|
||||
export const isTemplateTrue = (value: unknown): boolean => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return ['1', 'true', 'yes', 'on', 'enable'].includes(value.toLowerCase());
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
||||
import { TriggerOfType } from './types';
|
||||
|
||||
export class KeyTrigger extends ConditionStateTriggerBase<TriggerOfType<'key'>> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.keys;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
||||
import { TriggerOfType } from './types';
|
||||
|
||||
export class MediaLoadedTrigger extends ConditionStateTriggerBase<
|
||||
TriggerOfType<'media_loaded'>
|
||||
> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.mediaLoadedInfo != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
||||
import { TriggerOfType } from './types';
|
||||
|
||||
// Triggers when the microphone mute state changes: to the given value if `muted`
|
||||
// is set, or on any change if it is omitted.
|
||||
export class MicrophoneTrigger extends ConditionStateTriggerBase<
|
||||
TriggerOfType<'microphone'>
|
||||
> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.microphone?.muted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
import { matchesNumericState, readNumericStateValue } from '../../common/numeric-state';
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { EntityStateTriggerBase } from './entity-state-base';
|
||||
import { TriggerOfType } from './types';
|
||||
|
||||
// https://www.home-assistant.io/docs/automation/trigger/#numeric-state-trigger
|
||||
// Faithful to HA's numeric_state trigger
|
||||
// (homeassistant/components/homeassistant/triggers/numeric_state.py): triggers
|
||||
// on the *crossing* into the `above`/`below` range, not while merely in it.
|
||||
// Each entity is "armed" while outside the range and triggers once on the
|
||||
// transition in, then disarms until it leaves and re-arms. `for:` holds the
|
||||
// trigger only while the value stays in range. The in-range check is shared
|
||||
// with the numeric_state condition (`matchesNumericState`).
|
||||
export class NumericStateTrigger extends EntityStateTriggerBase<
|
||||
TriggerOfType<'numeric_state'>
|
||||
> {
|
||||
protected readonly _platform = 'numeric_state';
|
||||
|
||||
// Entities currently outside the range, armed to trigger on the next crossing in.
|
||||
private _armedEntities = new Set<string>();
|
||||
|
||||
protected _onSubscribe(): void {
|
||||
// Arm entities that start with a readable value outside the range, matching
|
||||
// HA: an unreadable entity is not armed, so it does not trigger on its first
|
||||
// valid in-range reading (only once it has been outside and crossed in).
|
||||
const state = this._context.stateManager.getState();
|
||||
for (const entityID of this._entityIDs()) {
|
||||
if (
|
||||
readNumericStateValue(
|
||||
entityID,
|
||||
state,
|
||||
this._trigger,
|
||||
this._context.templateRenderer,
|
||||
) !== null &&
|
||||
!this._matches(entityID, state)
|
||||
) {
|
||||
this._armedEntities.add(entityID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected _onDestroy(): void {
|
||||
this._armedEntities.clear();
|
||||
}
|
||||
|
||||
private _matches(entityID: string, state: ConditionState): boolean {
|
||||
return matchesNumericState(
|
||||
entityID,
|
||||
state,
|
||||
this._trigger,
|
||||
this._context.templateRenderer,
|
||||
);
|
||||
}
|
||||
|
||||
protected _processEntityChange(
|
||||
entityID: string,
|
||||
oldStateObj: HassEntity | undefined,
|
||||
newStateObj: HassEntity | undefined,
|
||||
): void {
|
||||
// During a state-change dispatch the manager's stored state is already the
|
||||
// new state, so it is what this entity just changed to.
|
||||
if (!this._matches(entityID, this._context.stateManager.getState())) {
|
||||
// Outside the range: (re-)arm, and cancel any pending `for:` hold.
|
||||
this._armedEntities.add(entityID);
|
||||
this._cancelForTimer(entityID);
|
||||
return;
|
||||
}
|
||||
|
||||
// Inside the range but not armed: already in range, so not a crossing.
|
||||
if (!this._armedEntities.has(entityID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Crossing into the range: disarm and trigger (or start the `for:` hold).
|
||||
this._armedEntities.delete(entityID);
|
||||
this._callTriggerOrHold(entityID, oldStateObj, newStateObj);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
MediaQueryWatcher,
|
||||
MediaQueryWatcherUnsubscribeCallback,
|
||||
} from '../../common/media-query-watcher';
|
||||
import { buildCardTriggerData } from '../build-trigger-data';
|
||||
import { TriggerCallback, TriggerEvaluator, TriggerOfType } from './types';
|
||||
|
||||
// `screen` watches a matchMedia query, whose state lives outside the card's
|
||||
// `ConditionState`, so it owns a `MediaQueryWatcher` (the same watcher the
|
||||
// screen condition uses). It fires on the rising edge of the query match --
|
||||
// consistent with "value present => fire on change to that value".
|
||||
export class ScreenTrigger implements TriggerEvaluator {
|
||||
private _trigger: TriggerOfType<'screen'>;
|
||||
|
||||
private _callback: TriggerCallback | null = null;
|
||||
private _watcher: MediaQueryWatcher | null = null;
|
||||
private _unsubscribeCallback: MediaQueryWatcherUnsubscribeCallback | null = null;
|
||||
private _matched = false;
|
||||
|
||||
constructor(trigger: TriggerOfType<'screen'>) {
|
||||
this._trigger = trigger;
|
||||
}
|
||||
|
||||
public subscribe(callback: TriggerCallback): void {
|
||||
if (!this._trigger.media_query) {
|
||||
return;
|
||||
}
|
||||
this._callback = callback;
|
||||
this._watcher = new MediaQueryWatcher(this._trigger.media_query);
|
||||
this._matched = this._watcher.matches();
|
||||
this._unsubscribeCallback = this._watcher.subscribe(this._handler);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._unsubscribeCallback?.();
|
||||
this._unsubscribeCallback = null;
|
||||
this._watcher = null;
|
||||
this._callback = null;
|
||||
}
|
||||
|
||||
private _handler = (): void => {
|
||||
const matched = !!this._watcher?.matches();
|
||||
if (matched && !this._matched) {
|
||||
this._callback?.(buildCardTriggerData(this._trigger.trigger));
|
||||
}
|
||||
this._matched = matched;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
import { arrayify } from '../../../utils/basic';
|
||||
import { EntityStateTriggerBase } from './entity-state-base';
|
||||
import { TriggerOfType } from './types';
|
||||
|
||||
// https://www.home-assistant.io/docs/automation/trigger/#state-trigger Faithful
|
||||
// to HA's state trigger
|
||||
// (homeassistant/components/homeassistant/triggers/state.py):
|
||||
// per-`entity`/`entity_id`, `from`/`to`/`not_from`/`not_to` matchers (absent or
|
||||
// `null` matches anything), the `match_all` attribute-triggering rule,
|
||||
// `attribute`, and a per-entity `for:`.
|
||||
export class StateTrigger extends EntityStateTriggerBase<TriggerOfType<'state'>> {
|
||||
protected readonly _platform = 'state';
|
||||
|
||||
// True when any of `from`/`to`/`not_from`/`not_to` is set. A `null` config value
|
||||
// (e.g. `to: null`) still counts as set: it matches any state value (see
|
||||
// `_matches`), but its mere presence is what restricts triggering to *real
|
||||
// state changes*. This is the whole significance of `to: null` vs omitting
|
||||
// `to`: both match any value, but `to: null` will NOT trigger on attribute-only
|
||||
// changes (a constraint exists), whereas omitting all four triggers on those
|
||||
// too.
|
||||
private _hasStateConstraint(): boolean {
|
||||
const trigger = this._trigger;
|
||||
return (
|
||||
trigger.from !== undefined ||
|
||||
trigger.not_from !== undefined ||
|
||||
trigger.to !== undefined ||
|
||||
trigger.not_to !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
private _readValue(stateObj?: HassEntity): string | null {
|
||||
if (!stateObj) {
|
||||
return null;
|
||||
}
|
||||
const attribute = this._trigger.attribute;
|
||||
if (attribute !== undefined) {
|
||||
const value = stateObj.attributes?.[attribute];
|
||||
return value === undefined || value === null ? null : String(value);
|
||||
}
|
||||
return stateObj.state;
|
||||
}
|
||||
|
||||
// A value matches when it is in the positive set (`from`/`to`), or not in the
|
||||
// negative set (`not_from`/`not_to`); an absent or `null` constraint matches
|
||||
// anything.
|
||||
private _matches(
|
||||
value: string | null,
|
||||
positive?: string | string[] | null,
|
||||
negative?: string | string[] | null,
|
||||
): boolean {
|
||||
if (positive !== undefined && positive !== null) {
|
||||
return value !== null && arrayify(positive).includes(value);
|
||||
}
|
||||
if (negative !== undefined && negative !== null) {
|
||||
// An absent value (entity missing) is not in the set, so it matches.
|
||||
return !(value !== null && arrayify(negative).includes(value));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected _processEntityChange(
|
||||
entityID: string,
|
||||
oldStateObj: HassEntity | undefined,
|
||||
newStateObj: HassEntity | undefined,
|
||||
): void {
|
||||
const trigger = this._trigger;
|
||||
const oldValue = this._readValue(oldStateObj);
|
||||
const newValue = this._readValue(newStateObj);
|
||||
|
||||
// When watching an attribute, ignore changes that don't move it.
|
||||
if (trigger.attribute !== undefined && oldValue === newValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasStateConstraint = this._hasStateConstraint();
|
||||
const matches =
|
||||
this._matches(oldValue, trigger.from, trigger.not_from) &&
|
||||
this._matches(newValue, trigger.to, trigger.not_to) &&
|
||||
// from/to test the values but not that they *differ*, so an attribute-only
|
||||
// event (value unchanged) can still satisfy them. Require a genuine change
|
||||
// when a constraint is set; with none, trigger on those too.
|
||||
!(hasStateConstraint && oldValue === newValue);
|
||||
|
||||
if (!matches) {
|
||||
// Only a real change of the watched value cancels a pending `for:` hold;
|
||||
// an attribute-only change (value unchanged) must leave it running, just
|
||||
// as HA's `for:` keys off the state, not the whole state object.
|
||||
if (oldValue !== newValue) {
|
||||
this._cancelForTimer(entityID);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._callTriggerOrHold(entityID, oldStateObj, newStateObj);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Timer } from '../../../utils/timer';
|
||||
import { renderTimePeriodToSeconds } from '../../common/time-period';
|
||||
import { ConditionState, ConditionStateChange } from '../../conditions/types';
|
||||
import { isTemplateTrue } from './is-template-true';
|
||||
import {
|
||||
TriggerCallback,
|
||||
TriggerEvaluator,
|
||||
TriggerEvaluatorContext,
|
||||
TriggerOfType,
|
||||
} from './types';
|
||||
|
||||
// https://www.home-assistant.io/docs/automation/trigger/#template-trigger
|
||||
// Triggers when `value_template` renders true having previously been non-true
|
||||
// (the rising edge); `for:` requires it to stay true for the duration first.
|
||||
export class TemplateTrigger implements TriggerEvaluator {
|
||||
private _trigger: TriggerOfType<'template'>;
|
||||
private _context: TriggerEvaluatorContext;
|
||||
|
||||
private _callback: TriggerCallback | null = null;
|
||||
private _forTimer = new Timer();
|
||||
|
||||
private _lastResult = false;
|
||||
|
||||
constructor(trigger: TriggerOfType<'template'>, context: TriggerEvaluatorContext) {
|
||||
this._trigger = trigger;
|
||||
this._context = context;
|
||||
}
|
||||
|
||||
public subscribe(callback: TriggerCallback): void {
|
||||
this._callback = callback;
|
||||
|
||||
// Establish the baseline without triggering: a template already true at
|
||||
// subscribe must not trigger (HA triggers only on a transition to true).
|
||||
this._lastResult = this._render(this._context.stateManager.getState());
|
||||
this._context.stateManager.addListener(this._handler);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._context.stateManager.removeListener(this._handler);
|
||||
this._forTimer.stop();
|
||||
this._callback = null;
|
||||
}
|
||||
|
||||
private _handler = (change: ConditionStateChange): void => {
|
||||
const result = this._render(change.new);
|
||||
const rising = result && !this._lastResult;
|
||||
this._lastResult = result;
|
||||
|
||||
if (rising) {
|
||||
const forPeriod = this._trigger.for;
|
||||
if (!forPeriod) {
|
||||
this._callTrigger();
|
||||
return;
|
||||
}
|
||||
const seconds = renderTimePeriodToSeconds(
|
||||
this._context.templateRenderer,
|
||||
forPeriod,
|
||||
change.new,
|
||||
);
|
||||
if (seconds !== null) {
|
||||
this._forTimer.start(seconds, () => this._callTrigger());
|
||||
}
|
||||
} else if (!result) {
|
||||
// No longer holds -- cancel any pending `for:` trigger.
|
||||
this._forTimer.stop();
|
||||
}
|
||||
};
|
||||
|
||||
private _render(state: ConditionState): boolean {
|
||||
return (
|
||||
!!state.hass &&
|
||||
isTemplateTrue(
|
||||
this._context.templateRenderer.renderRecursively(
|
||||
state.hass,
|
||||
this._trigger.value_template,
|
||||
{ conditionState: state },
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private _callTrigger(): void {
|
||||
this._callback?.({ platform: 'template' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
||||
import { TriggerOfType } from './types';
|
||||
|
||||
export class TriggeredTrigger extends ConditionStateTriggerBase<
|
||||
TriggerOfType<'triggered'>
|
||||
> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.triggered;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { TemplateRenderer } from '../../../card-controller/templates';
|
||||
import { Trigger } from '../../../config/schema/condition-trigger/triggers/types';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
|
||||
import { TriggerData } from '../types';
|
||||
|
||||
export type TriggerCallback = (data: TriggerData) => void;
|
||||
|
||||
export interface TriggerEvaluatorContext {
|
||||
stateManager: ConditionStateManagerReadonlyInterface;
|
||||
templateRenderer: TemplateRenderer;
|
||||
}
|
||||
|
||||
export type TriggerOfType<T extends string> = Extract<Trigger, { trigger: T }>;
|
||||
|
||||
/**
|
||||
* A single trigger built from one `triggers:` entry: it watches its source and
|
||||
* invokes `callback` when its event occurs. The push-based sibling of the
|
||||
* pull-based `ConditionEvaluator`.
|
||||
*/
|
||||
export interface TriggerEvaluator {
|
||||
subscribe(callback: TriggerCallback): void;
|
||||
destroy(): void;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
||||
import { TriggerOfType } from './types';
|
||||
|
||||
// Triggers when the selected view changes: to one of `views` if listed, or on
|
||||
// any change if `views` is omitted.
|
||||
export class ViewTrigger extends ConditionStateTriggerBase<TriggerOfType<'view'>> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.view;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user