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
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { KeyedSubscriptionManager } from '../../utils/keyed-subscription-manager';
|
||||
import { KeyedSubscriptionManager } from '../../utils/concurrency/keyed-subscription-manager';
|
||||
import {
|
||||
FrigateEventChange,
|
||||
FrigateReviewChange,
|
||||
|
||||
@@ -203,7 +203,8 @@ export class CameraManager {
|
||||
const engine = engineType
|
||||
? engines.get(engineType) ??
|
||||
(await this._engineFactory.createEngine(engineType, {
|
||||
eventCallback: (ev) => this._api.getTriggersManager().handleCameraEvent(ev),
|
||||
eventCallback: (ev) =>
|
||||
this._api.getCameraTriggersManager().handleCameraEvent(ev),
|
||||
stateWatcher: this._api.getHASSManager().getStateWatcher(),
|
||||
eventWatcher: this._api.getHASSManager().getEventWatcher(),
|
||||
resolvedMediaCache: this._api.getResolvedMediaCache(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ActionContext } from 'action';
|
||||
import { z } from 'zod';
|
||||
import { TriggerData } from '../../condition-trigger/triggers/types.js';
|
||||
import {
|
||||
ActionConfig,
|
||||
Actions,
|
||||
@@ -14,7 +15,11 @@ import { allPromises, errorToConsole } from '../../utils/basic.js';
|
||||
import { TemplateRenderer } from '../templates/index.js';
|
||||
import { CardActionsManagerAPI } from '../types.js';
|
||||
import { ActionSet } from './actions/set.js';
|
||||
import { ActionsExecutionRequest, ActionsExecutor } from './types.js';
|
||||
import {
|
||||
ActionPrepareCallback,
|
||||
ActionsExecutionRequest,
|
||||
ActionsExecutor,
|
||||
} from './types.js';
|
||||
|
||||
const INTERACTIONS = ['tap', 'double_tap', 'hold', 'start_tap', 'end_tap'] as const;
|
||||
export type InteractionName = (typeof INTERACTIONS)[number];
|
||||
@@ -138,23 +143,31 @@ export class ActionsManager implements ActionsExecutor {
|
||||
request: ActionsExecutionRequest,
|
||||
renderTemplates = true,
|
||||
): Promise<void> {
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
const renderedAction: ActionConfig | ActionConfig[] =
|
||||
renderTemplates && hass && this._templateRenderer
|
||||
? (this._templateRenderer.renderRecursively(hass, request.actions, {
|
||||
conditionState: this._api.getConditionStateManager().getState(),
|
||||
triggerData: request?.triggerData,
|
||||
}) as ActionConfig | ActionConfig[])
|
||||
: request.actions;
|
||||
|
||||
const allowedActions = this._api.getLockManager().getAllowedActions(renderedAction);
|
||||
// Lock filtering and the factory both classify on the raw (unrendered)
|
||||
// discriminator (`action` / `advanced_camera_card_action`). A templated
|
||||
// `advanced_camera_card_action` (permitted only by the loose custom-action
|
||||
// schema) is left unresolved, matches no action type, and is dropped.
|
||||
const allowedActions = this._api.getLockManager().getAllowedActions(request.actions);
|
||||
if (!allowedActions.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Each action prepares itself (via Action.prepare) just before it runs, so
|
||||
// it observes what an earlier action may have changed. Skip when the caller
|
||||
// opts out (the templates are already rendered) or there is no renderer.
|
||||
const renderer = this._templateRenderer;
|
||||
const actionPrepareCallback =
|
||||
renderTemplates && renderer
|
||||
? this._createActionPrepareCallback(renderer, request.triggerData)
|
||||
: undefined;
|
||||
|
||||
const actionSet = new ActionSet(this._actionContext, allowedActions, {
|
||||
config: request.config,
|
||||
cardID: this._api.getConfigManager().getConfig()?.card_id,
|
||||
factoryOptions: {
|
||||
config: request.config,
|
||||
cardID: this._api.getConfigManager().getConfig()?.card_id,
|
||||
triggerData: request?.triggerData,
|
||||
},
|
||||
actionPrepareCallback,
|
||||
});
|
||||
|
||||
this._actionsInFlight.push(actionSet);
|
||||
@@ -168,4 +181,22 @@ export class ActionsManager implements ActionsExecutor {
|
||||
}
|
||||
this._actionsInFlight = this._actionsInFlight.filter((a) => a !== actionSet);
|
||||
}
|
||||
|
||||
private _createActionPrepareCallback(
|
||||
renderer: TemplateRenderer,
|
||||
triggerData?: TriggerData,
|
||||
): ActionPrepareCallback {
|
||||
// Render against the state (incl. HASS) as it is *when the action runs* --
|
||||
// fixed trigger context, fresh card/HASS state per step. The one cast lives
|
||||
// here as renderRecursively returns `unknown`.
|
||||
return <T>(value: T): T => {
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
return hass
|
||||
? (renderer.renderRecursively(hass, value, {
|
||||
conditionState: this._api.getConditionStateManager().getState(),
|
||||
triggerData,
|
||||
}) as T)
|
||||
: value;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,24 +4,35 @@ import {
|
||||
AuxillaryActionConfig,
|
||||
} from '../../../config/schema/actions/types.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
import { isAdvancedCameraCardCustomAction } from '../../../utils/action';
|
||||
import { getActionName } from '../../../utils/action';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { Action, ActionAbortError } from '../types';
|
||||
import { Action, ActionAbortError, ActionPrepareCallback } from '../types';
|
||||
|
||||
export class BaseAction<T extends ActionConfig> implements Action {
|
||||
protected _context: ActionContext;
|
||||
protected _action: T;
|
||||
protected _rawAction: T;
|
||||
protected _preparedAction: T | null = null;
|
||||
protected _config?: AuxillaryActionConfig;
|
||||
|
||||
constructor(context: ActionContext, action: T, config?: AuxillaryActionConfig) {
|
||||
this._context = context;
|
||||
this._action = action;
|
||||
this._rawAction = action;
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
public prepare(actionPrepareCallback: ActionPrepareCallback): void {
|
||||
this._preparedAction = actionPrepareCallback(this._rawAction);
|
||||
}
|
||||
|
||||
// The config to act on: the prepared (rendered) form once prepare() has run,
|
||||
// otherwise the raw config (e.g. under direct execution without a prepare).
|
||||
protected _getAction(): T {
|
||||
return this._preparedAction ?? this._rawAction;
|
||||
}
|
||||
|
||||
protected _shouldSeekConfirmation(api: CardActionsAPI): boolean {
|
||||
const hass = api.getHASSManager().getHASS();
|
||||
const action: ActionConfig = this._action;
|
||||
const action: ActionConfig = this._getAction();
|
||||
|
||||
return (
|
||||
(typeof action.confirmation === 'boolean' && action.confirmation) ||
|
||||
@@ -33,14 +44,10 @@ export class BaseAction<T extends ActionConfig> implements Action {
|
||||
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
if (this._shouldSeekConfirmation(api)) {
|
||||
const action: ActionConfig = this._action;
|
||||
const baseAction = action.action;
|
||||
const actionName = isAdvancedCameraCardCustomAction(action)
|
||||
? action.advanced_camera_card_action
|
||||
: baseAction;
|
||||
const action: ActionConfig = this._getAction();
|
||||
const text =
|
||||
(typeof action.confirmation === 'object' ? action.confirmation.text : null) ??
|
||||
`${localize('actions.confirmation')}: ${actionName}`;
|
||||
`${localize('actions.confirmation')}: ${getActionName(action)}`;
|
||||
if (!confirm(text)) {
|
||||
throw new ActionAbortError(localize('actions.abort'));
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ export class CallServiceAction extends AdvancedCameraCardAction<CallServiceActio
|
||||
return;
|
||||
}
|
||||
|
||||
const [domain, service] = this._action.service.split('.', 2);
|
||||
await hass.callService(domain, service, this._action.data, this._action.target);
|
||||
const action = this._getAction();
|
||||
const [domain, service] = action.service.split('.', 2);
|
||||
await hass.callService(domain, service, action.data, action.target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ export class CallStartAction extends AdvancedCameraCardAction<CallStartActionCon
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
const action = this._getAction();
|
||||
await api.getCallManager().start({
|
||||
cameraID: this._action.camera,
|
||||
streamID: this._action.stream,
|
||||
cameraID: action.camera,
|
||||
streamID: action.stream,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ export class CameraSelectAction extends AdvancedCameraCardAction<CameraSelectAct
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
const action = this._getAction();
|
||||
const selectCameraID =
|
||||
this._action.camera ??
|
||||
(this._action.triggered
|
||||
? api.getTriggersManager().getMostRecentlyTriggeredCameraID()
|
||||
action.camera ??
|
||||
(action.triggered
|
||||
? api.getCameraTriggersManager().getMostRecentlyTriggeredCameraID()
|
||||
: null);
|
||||
const view = api.getViewManager().getView();
|
||||
const config = api.getConfigManager().getConfig();
|
||||
|
||||
@@ -7,6 +7,10 @@ export class CustomAction extends AdvancedCameraCardAction<CustomActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
fireHASSEvent(api.getCardElementManager().getElement(), 'll-custom', this._action);
|
||||
fireHASSEvent(
|
||||
api.getCardElementManager().getElement(),
|
||||
'll-custom',
|
||||
this._getAction(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export class DisplayModeSelectAction extends AdvancedCameraCardAction<DisplayMod
|
||||
|
||||
await api.getViewManager().setViewByParametersWithNewQuery({
|
||||
params: {
|
||||
displayMode: this._action.display_mode,
|
||||
displayMode: this._getAction().display_mode,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,15 +6,16 @@ export class EffectAction extends AdvancedCameraCardAction<EffectActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
switch (this._action.effect_action) {
|
||||
const action = this._getAction();
|
||||
switch (action.effect_action) {
|
||||
case 'start':
|
||||
api.getEffectsManager().startEffect(this._action.effect);
|
||||
api.getEffectsManager().startEffect(action.effect);
|
||||
break;
|
||||
case 'stop':
|
||||
api.getEffectsManager().stopEffect(this._action.effect);
|
||||
api.getEffectsManager().stopEffect(action.effect);
|
||||
break;
|
||||
case 'toggle':
|
||||
api.getEffectsManager().toggleEffect(this._action.effect);
|
||||
api.getEffectsManager().toggleEffect(action.effect);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ActionContext } from 'action';
|
||||
import { createConditionEvaluator } from '../../../condition-trigger/conditions/factory';
|
||||
import { TriggerData } from '../../../condition-trigger/triggers/types';
|
||||
import {
|
||||
AuxillaryActionConfig,
|
||||
IfActionConfig,
|
||||
} from '../../../config/schema/actions/types';
|
||||
import { TemplateRenderer } from '../../templates/index';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { ActionPrepareCallback } from '../types';
|
||||
import { BaseAction } from './base';
|
||||
|
||||
export class IfAction extends BaseAction<IfActionConfig> {
|
||||
private _triggerData?: TriggerData;
|
||||
|
||||
constructor(
|
||||
context: ActionContext,
|
||||
action: IfActionConfig,
|
||||
config?: AuxillaryActionConfig,
|
||||
triggerData?: TriggerData,
|
||||
) {
|
||||
super(context, action, config);
|
||||
|
||||
this._triggerData = triggerData;
|
||||
}
|
||||
|
||||
public prepare(actionPrepareCallback: ActionPrepareCallback): void {
|
||||
// Render this action's own fields (including the `if` conditions, so their
|
||||
// `trigger.*` templates resolve), but leave `then`/`else` raw: they are
|
||||
// nested action sequences that render per-step when their own branch runs.
|
||||
const { then: thenBranch, else: elseBranch, ...rest } = this._rawAction;
|
||||
this._preparedAction = {
|
||||
...actionPrepareCallback(rest),
|
||||
then: thenBranch,
|
||||
...(elseBranch !== undefined && { else: elseBranch }),
|
||||
};
|
||||
}
|
||||
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
const action = this._getAction();
|
||||
const evaluatorContext = { templateRenderer: new TemplateRenderer() };
|
||||
const state = api.getConditionStateManager().getState();
|
||||
const conditionsHold = action.if.every(
|
||||
(condition) =>
|
||||
createConditionEvaluator(condition, evaluatorContext).evaluate(state).result,
|
||||
);
|
||||
|
||||
const branch = conditionsHold ? action.then : action.else;
|
||||
if (!branch?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The branch renders per-step as it runs, so each action observes state an
|
||||
// earlier branch action changed. The trigger data is forwarded so the
|
||||
// branch can still resolve `trigger.*` templates.
|
||||
await api.getActionsManager().executeActions({
|
||||
actions: branch,
|
||||
config: this._config,
|
||||
triggerData: this._triggerData,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,6 @@ export class InternalCallbackAction extends AdvancedCameraCardAction<InternalCal
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
await this._action.callback(api);
|
||||
await this._getAction().callback(api);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export class LogAction extends AdvancedCameraCardAction<LogActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
console[this._action.level](this._action.message);
|
||||
const action = this._getAction();
|
||||
console[action.level](action.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@ export class MediaPlayerAction extends AdvancedCameraCardAction<MediaPlayerActio
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
const mediaPlayer = this._action.media_player;
|
||||
const action = this._getAction();
|
||||
const mediaPlayer = action.media_player;
|
||||
const mediaPlayerController = api.getMediaPlayerManager();
|
||||
|
||||
if (this._action.media_player_action === 'stop') {
|
||||
if (action.media_player_action === 'stop') {
|
||||
await mediaPlayerController.stop(mediaPlayer);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export class MoreInfoAction extends AdvancedCameraCardAction<MoreInfoActionConfi
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
const entityID = this._action.entity ?? this._config?.entity ?? null;
|
||||
const entityID = this._getAction().entity ?? this._config?.entity ?? null;
|
||||
if (!entityID) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ export class NavigateAction extends AdvancedCameraCardAction<NavigateActionConfi
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
if (!!this._action.navigation_replace) {
|
||||
history.replaceState(null, '', this._action.navigation_path);
|
||||
const action = this._getAction();
|
||||
if (!!action.navigation_replace) {
|
||||
history.replaceState(null, '', action.navigation_path);
|
||||
} else {
|
||||
history.pushState(null, '', this._action.navigation_path);
|
||||
history.pushState(null, '', action.navigation_path);
|
||||
}
|
||||
fireHASSEvent(window, 'location-changed', {
|
||||
replace: !!this._action.navigation_replace,
|
||||
replace: !!action.navigation_replace,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,6 @@ import { AdvancedCameraCardAction } from './base';
|
||||
export class NotificationAction extends AdvancedCameraCardAction<NotificationActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
api.getNotificationManager().setNotification(this._action.notification);
|
||||
api.getNotificationManager().setNotification(this._getAction().notification);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ export class PerformActionAction extends AdvancedCameraCardAction<PerformActionA
|
||||
return;
|
||||
}
|
||||
|
||||
const [domain, service] = this._action.perform_action.split('.', 2);
|
||||
await hass.callService(domain, service, this._action.data, this._action.target);
|
||||
const action = this._getAction();
|
||||
const [domain, service] = action.perform_action.split('.', 2);
|
||||
await hass.callService(domain, service, action.data, action.target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export class PTZControlsAction extends AdvancedCameraCardAction<PTZControlsActio
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
const action = this._getAction();
|
||||
const currentEnabled = api.getViewManager().getView()?.context?.ptzControls?.enabled;
|
||||
|
||||
// If `enabled` is explicit, use it. If only `type` is being changed, leave
|
||||
@@ -13,8 +14,8 @@ export class PTZControlsAction extends AdvancedCameraCardAction<PTZControlsActio
|
||||
// toggle the current enabled value — this is the menu-button show/hide use
|
||||
// case.
|
||||
const enabled =
|
||||
this._action.enabled ??
|
||||
(this._action.type
|
||||
action.enabled ??
|
||||
(action.type
|
||||
? undefined
|
||||
: currentEnabled === undefined
|
||||
? undefined
|
||||
@@ -23,7 +24,7 @@ export class PTZControlsAction extends AdvancedCameraCardAction<PTZControlsActio
|
||||
api.getViewManager().setViewWithMergedContext({
|
||||
ptzControls: {
|
||||
...(enabled !== undefined && { enabled }),
|
||||
...(this._action.type && { type: this._action.type }),
|
||||
...(action.type && { type: action.type }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,25 +48,26 @@ export class PTZDigitalAction extends AdvancedCameraCardAction<PTZDigitialAction
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
const action = this._getAction();
|
||||
const view = api.getViewManager().getView();
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetID =
|
||||
this._action.target_id ??
|
||||
action.target_id ??
|
||||
getPTZTarget(view, { type: 'digital', cameraManager: api.getCameraManager() })
|
||||
?.targetID;
|
||||
if (!targetID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!!this._action.absolute || !this._action.ptz_phase) {
|
||||
if (!!action.absolute || !action.ptz_phase) {
|
||||
return await this._stepChange(api, targetID);
|
||||
}
|
||||
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (this._action.ptz_phase === 'start') {
|
||||
if (action.ptz_phase === 'start') {
|
||||
await stopInProgressForThisTarget(targetID, this._context.ptzDigital);
|
||||
setInProgressForThisTarget(targetID, this._context, 'ptzDigital', this);
|
||||
|
||||
@@ -74,21 +75,22 @@ export class PTZDigitalAction extends AdvancedCameraCardAction<PTZDigitialAction
|
||||
this._timer.startRepeated(STEP_DELAY_SECONDS, () =>
|
||||
this._stepChange(api, targetID),
|
||||
);
|
||||
} else if (this._action.ptz_phase === 'stop') {
|
||||
} else if (action.ptz_phase === 'stop') {
|
||||
await stopInProgressForThisTarget(targetID, this._context.ptzDigital);
|
||||
delete this._context.ptzDigital?.[targetID];
|
||||
}
|
||||
}
|
||||
|
||||
private _convertActionToZoomSettings(base?: PartialZoomSettings): PartialZoomSettings {
|
||||
if (!this._action.absolute && !this._action.ptz_action) {
|
||||
const action = this._getAction();
|
||||
if (!action.absolute && !action.ptz_action) {
|
||||
// If neither an absolute position nor an action are specified, the request
|
||||
// is assumed to be to return to default.
|
||||
return {};
|
||||
}
|
||||
|
||||
if (this._action.absolute) {
|
||||
return this._action.absolute;
|
||||
if (action.absolute) {
|
||||
return action.absolute;
|
||||
}
|
||||
|
||||
const zoom = base?.zoom ?? ZOOM_DEFAULT_SCALE;
|
||||
@@ -98,21 +100,21 @@ export class PTZDigitalAction extends AdvancedCameraCardAction<PTZDigitialAction
|
||||
};
|
||||
|
||||
const zoomDelta =
|
||||
this._action.ptz_action === 'zoom_in'
|
||||
action.ptz_action === 'zoom_in'
|
||||
? STEP_ZOOM
|
||||
: this._action.ptz_action === 'zoom_out'
|
||||
: action.ptz_action === 'zoom_out'
|
||||
? -STEP_ZOOM
|
||||
: 0;
|
||||
const xDelta =
|
||||
this._action.ptz_action === 'left'
|
||||
action.ptz_action === 'left'
|
||||
? -STEP_PAN
|
||||
: this._action.ptz_action === 'right'
|
||||
: action.ptz_action === 'right'
|
||||
? STEP_PAN
|
||||
: 0;
|
||||
const yDelta =
|
||||
this._action.ptz_action === 'up'
|
||||
action.ptz_action === 'up'
|
||||
? -STEP_PAN
|
||||
: this._action.ptz_action === 'down'
|
||||
: action.ptz_action === 'down'
|
||||
? STEP_PAN
|
||||
: 0;
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ export class PTZMultiAction extends AdvancedCameraCardAction<PTZMultiActionConfi
|
||||
let targetID: string | null = null;
|
||||
let type: PTZType | null = null;
|
||||
|
||||
if (this._action.target_id) {
|
||||
targetID = this._action.target_id;
|
||||
const action = this._getAction();
|
||||
if (action.target_id) {
|
||||
targetID = action.target_id;
|
||||
type = hasCameraTruePTZ(api.getCameraManager(), targetID) ? 'ptz' : 'digital';
|
||||
} else if (view) {
|
||||
const multiTarget = getPTZTarget(view, { cameraManager: api.getCameraManager() });
|
||||
@@ -34,26 +35,28 @@ export class PTZMultiAction extends AdvancedCameraCardAction<PTZMultiActionConfi
|
||||
}
|
||||
|
||||
private _toPTZAction(targetID: string): PTZAction {
|
||||
const action = this._getAction();
|
||||
return new PTZAction(
|
||||
this._context,
|
||||
createPTZAction({
|
||||
cardID: this._action.card_id,
|
||||
cardID: action.card_id,
|
||||
cameraID: targetID,
|
||||
ptzAction: this._action.ptz_action,
|
||||
ptzPhase: this._action.ptz_phase,
|
||||
ptzPreset: this._action.ptz_preset,
|
||||
ptzAction: action.ptz_action,
|
||||
ptzPhase: action.ptz_phase,
|
||||
ptzPreset: action.ptz_preset,
|
||||
}),
|
||||
this._config,
|
||||
);
|
||||
}
|
||||
|
||||
private _toPTZDigitalAction(targetID: string): PTZDigitalAction {
|
||||
const action = this._getAction();
|
||||
return new PTZDigitalAction(
|
||||
this._context,
|
||||
createPTZDigitalAction({
|
||||
cardID: this._action.card_id,
|
||||
ptzPhase: this._action.ptz_phase,
|
||||
ptzAction: this._action.ptz_action,
|
||||
cardID: action.card_id,
|
||||
ptzPhase: action.ptz_phase,
|
||||
ptzAction: action.ptz_action,
|
||||
targetID: targetID,
|
||||
}),
|
||||
this._config,
|
||||
|
||||
@@ -33,13 +33,15 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
const action = this._getAction();
|
||||
|
||||
const view = api.getViewManager().getView();
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ptzCameraID =
|
||||
this._action.camera ??
|
||||
action.camera ??
|
||||
getPTZTarget(view, { type: 'ptz', cameraManager: api.getCameraManager() })
|
||||
?.targetID ??
|
||||
null;
|
||||
@@ -53,34 +55,34 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._action.ptz_action) {
|
||||
if (!action.ptz_action) {
|
||||
if (ptzCapabilities.presets && ptzCapabilities.presets.length >= 1) {
|
||||
await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', {
|
||||
phase: this._action.ptz_phase,
|
||||
phase: action.ptz_phase,
|
||||
preset: ptzCapabilities.presets[0],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const capabilityKey = ptzActionToCapabilityKey(this._action.ptz_action);
|
||||
const capabilityKey = ptzActionToCapabilityKey(action.ptz_action);
|
||||
if (
|
||||
(capabilityKey &&
|
||||
ptzCapabilities[capabilityKey]?.includes(
|
||||
this._action.ptz_phase ? PTZMovementType.Continuous : PTZMovementType.Relative,
|
||||
action.ptz_phase ? PTZMovementType.Continuous : PTZMovementType.Relative,
|
||||
)) ||
|
||||
this._action.ptz_action === 'preset'
|
||||
action.ptz_action === 'preset'
|
||||
) {
|
||||
// Scenario: Camera natively supports requested move type.
|
||||
return await api
|
||||
.getCameraManager()
|
||||
.executePTZAction(ptzCameraID, this._action.ptz_action, {
|
||||
phase: this._action.ptz_phase,
|
||||
preset: this._action.ptz_preset,
|
||||
.executePTZAction(ptzCameraID, action.ptz_action, {
|
||||
phase: action.ptz_phase,
|
||||
preset: action.ptz_preset,
|
||||
});
|
||||
}
|
||||
|
||||
if (this._action.ptz_phase === 'start') {
|
||||
if (action.ptz_phase === 'start') {
|
||||
// Scenario: Asked to start a continuous move, camera only supports relative moves natively.
|
||||
await stopInProgressForThisTarget(ptzCameraID, this._context.ptz);
|
||||
setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this);
|
||||
@@ -88,12 +90,10 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
|
||||
const singleStep = async (): Promise<void> => {
|
||||
/* istanbul ignore else: the else path cannot be reached as ptz_action
|
||||
being present is checked above -- @preserve */
|
||||
if (this._action.ptz_action) {
|
||||
await api
|
||||
.getCameraManager()
|
||||
.executePTZAction(ptzCameraID, this._action.ptz_action, {
|
||||
preset: this._action.ptz_preset,
|
||||
});
|
||||
if (action.ptz_action) {
|
||||
await api.getCameraManager().executePTZAction(ptzCameraID, action.ptz_action, {
|
||||
preset: action.ptz_preset,
|
||||
});
|
||||
}
|
||||
|
||||
if (!this._stopped) {
|
||||
@@ -109,30 +109,26 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
|
||||
|
||||
this._stopped = false;
|
||||
await singleStep();
|
||||
} else if (this._action.ptz_phase === 'stop') {
|
||||
} else if (action.ptz_phase === 'stop') {
|
||||
// Scenario: Asked to stop continuous move, camera only supports relative moves natively.
|
||||
await stopInProgressForThisTarget(ptzCameraID, this._context.ptz);
|
||||
} else {
|
||||
this._stopped = false;
|
||||
|
||||
// Relative move (but camera only supports continuous).
|
||||
await api
|
||||
.getCameraManager()
|
||||
.executePTZAction(ptzCameraID, this._action.ptz_action, {
|
||||
preset: this._action.ptz_preset,
|
||||
phase: 'start',
|
||||
});
|
||||
await api.getCameraManager().executePTZAction(ptzCameraID, action.ptz_action, {
|
||||
preset: action.ptz_preset,
|
||||
phase: 'start',
|
||||
});
|
||||
|
||||
this._timer.start(ptzConfiguration.c2r_delay_between_calls_seconds, async () => {
|
||||
/* istanbul ignore else: the else path cannot be reached as ptz_action
|
||||
being present is checked above -- @preserve */
|
||||
if (this._action.ptz_action) {
|
||||
await api
|
||||
.getCameraManager()
|
||||
.executePTZAction(ptzCameraID, this._action.ptz_action, {
|
||||
preset: this._action.ptz_preset,
|
||||
phase: 'stop',
|
||||
});
|
||||
if (action.ptz_action) {
|
||||
await api.getCameraManager().executePTZAction(ptzCameraID, action.ptz_action, {
|
||||
preset: action.ptz_preset,
|
||||
phase: 'stop',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export class SetReviewAction extends AdvancedCameraCardAction<SetReviewActionCon
|
||||
return;
|
||||
}
|
||||
|
||||
const targetReviewedState = this._action.reviewed;
|
||||
const targetReviewedState = this._getAction().reviewed;
|
||||
if (targetReviewedState !== undefined && targetReviewedState === item.isReviewed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
import { ActionContext } from 'action';
|
||||
import {
|
||||
ActionConfig,
|
||||
AuxillaryActionConfig,
|
||||
} from '../../../config/schema/actions/types';
|
||||
import { ActionConfig } from '../../../config/schema/actions/types';
|
||||
import { arrayify } from '../../../utils/basic';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { ActionFactory } from '../factory';
|
||||
import { Action } from '../types';
|
||||
import { ActionFactory, ActionFactoryOptions } from '../factory';
|
||||
import { ActionPrepareCallback } from '../types';
|
||||
|
||||
export class ActionSet implements Action {
|
||||
interface ActionSetOptions {
|
||||
factoryOptions?: ActionFactoryOptions;
|
||||
actionPrepareCallback?: ActionPrepareCallback;
|
||||
}
|
||||
|
||||
export class ActionSet {
|
||||
private _context: ActionContext;
|
||||
private _actions: Action[] = [];
|
||||
private _actions: ActionConfig[];
|
||||
private _factoryOptions?: ActionFactoryOptions;
|
||||
private _actionPrepareCallback?: ActionPrepareCallback;
|
||||
private _factory = new ActionFactory();
|
||||
private _stopped = false;
|
||||
|
||||
constructor(
|
||||
context: ActionContext,
|
||||
actions: ActionConfig | ActionConfig[],
|
||||
options?: {
|
||||
config?: AuxillaryActionConfig;
|
||||
cardID?: string;
|
||||
},
|
||||
options?: ActionSetOptions,
|
||||
) {
|
||||
this._context = context;
|
||||
for (const actionObj of arrayify(actions)) {
|
||||
const action = this._factory.createAction(context, actionObj, options);
|
||||
if (action) {
|
||||
this._actions.push(action);
|
||||
}
|
||||
}
|
||||
this._actions = arrayify(actions);
|
||||
this._actionPrepareCallback = options?.actionPrepareCallback;
|
||||
this._factoryOptions = options?.factoryOptions;
|
||||
}
|
||||
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
@@ -37,7 +35,20 @@ export class ActionSet implements Action {
|
||||
break;
|
||||
}
|
||||
|
||||
await action.execute(api);
|
||||
const concreteAction = this._factory.createAction(
|
||||
this._context,
|
||||
action,
|
||||
this._factoryOptions,
|
||||
);
|
||||
if (concreteAction) {
|
||||
// Prepare against the state as it is now, so an action observes what an
|
||||
// earlier action in the sequence changed. A prepare error aborts the
|
||||
// rest of the sequence (it propagates to the caller's handler).
|
||||
if (this._actionPrepareCallback) {
|
||||
concreteAction.prepare(this._actionPrepareCallback);
|
||||
}
|
||||
await concreteAction.execute(api);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@ export class SleepAction extends AdvancedCameraCardAction<SleepActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
await sleep(timeDeltaToSeconds(this._action.duration));
|
||||
await sleep(timeDeltaToSeconds(this._getAction().duration));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,17 +6,18 @@ export class StatusBarAction extends AdvancedCameraCardAction<StatusBarActionCon
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
switch (this._action.status_bar_action) {
|
||||
const action = this._getAction();
|
||||
switch (action.status_bar_action) {
|
||||
case 'reset':
|
||||
api.getStatusBarItemManager().removeAllDynamicStatusBarItems();
|
||||
break;
|
||||
case 'add':
|
||||
this._action.items?.forEach((item) =>
|
||||
action.items?.forEach((item) =>
|
||||
api.getStatusBarItemManager().addDynamicStatusBarItem(item),
|
||||
);
|
||||
break;
|
||||
case 'remove':
|
||||
this._action.items?.forEach((item) =>
|
||||
action.items?.forEach((item) =>
|
||||
api.getStatusBarItemManager().removeDynamicStatusBarItem(item),
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -8,7 +8,7 @@ export class SubstreamOffAction extends AdvancedCameraCardAction<SubstreamOffAct
|
||||
await super.execute(api);
|
||||
|
||||
api.getViewManager().setViewByParameters({
|
||||
modifiers: [new SubstreamViewModifier({ camera: this._action.camera })],
|
||||
modifiers: [new SubstreamViewModifier({ camera: this._getAction().camera })],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,14 @@ export class SubstreamOnAction extends AdvancedCameraCardAction<SubstreamOnActio
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraID = this._action.camera ?? view.camera;
|
||||
const action = this._getAction();
|
||||
const cameraID = action.camera ?? view.camera;
|
||||
if (!cameraID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream =
|
||||
this._action.stream ??
|
||||
action.stream ??
|
||||
this._getCycledSubstreamID(view, cameraID, api.getCameraManager());
|
||||
|
||||
api.getViewManager().setViewByParameters({
|
||||
|
||||
@@ -6,6 +6,6 @@ export class URLAction extends AdvancedCameraCardAction<URLActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
window.open(this._action.url_path);
|
||||
window.open(this._getAction().url_path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ export class ViewAction extends AdvancedCameraCardAction<ViewActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
const action = this._getAction();
|
||||
await api.getViewManager().setViewByParametersWithNewQuery({
|
||||
params: {
|
||||
view: this._action.advanced_camera_card_action,
|
||||
view: action.advanced_camera_card_action,
|
||||
},
|
||||
...(this._action.folder && {
|
||||
...(action.folder && {
|
||||
queryExecutorOptions: {
|
||||
folder: this._action.folder,
|
||||
folder: action.folder,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ActionContext } from 'action';
|
||||
import { TriggerData } from '../../condition-trigger/triggers/types';
|
||||
import { INTERNAL_CALLBACK_ACTION } from '../../config/schema/actions/custom/internal';
|
||||
import { ActionConfig, AuxillaryActionConfig } from '../../config/schema/actions/types';
|
||||
import { isAdvancedCameraCardCustomAction } from '../../utils/action';
|
||||
import { isAdvancedCameraCardCustomAction, isIfAction } from '../../utils/action';
|
||||
import { CallAnswerAction } from './actions/call-answer';
|
||||
import { CallEndAction } from './actions/call-end';
|
||||
import { CallServiceAction } from './actions/call-service';
|
||||
@@ -15,6 +16,7 @@ import { DownloadAction } from './actions/download';
|
||||
import { EffectAction } from './actions/effect';
|
||||
import { ExpandAction } from './actions/expand';
|
||||
import { FullscreenAction } from './actions/fullscreen';
|
||||
import { IfAction } from './actions/if';
|
||||
import { InfoAction } from './actions/info';
|
||||
import { InternalCallbackAction } from './actions/internal-callback';
|
||||
import { LogAction } from './actions/log';
|
||||
@@ -50,23 +52,36 @@ import { URLAction } from './actions/url';
|
||||
import { ViewAction } from './actions/view';
|
||||
import { Action } from './types';
|
||||
|
||||
export interface ActionFactoryOptions {
|
||||
config?: AuxillaryActionConfig;
|
||||
cardID?: string;
|
||||
|
||||
// The firing automation's trigger payload (if any), forwarded to actions with
|
||||
// nested actions (e.g. `if`) so their branches can still resolve `trigger.*`
|
||||
// templates when they render per-step.
|
||||
triggerData?: TriggerData;
|
||||
}
|
||||
|
||||
export class ActionFactory {
|
||||
public createAction(
|
||||
context: ActionContext,
|
||||
action: ActionConfig,
|
||||
options?: {
|
||||
config?: AuxillaryActionConfig;
|
||||
cardID?: string;
|
||||
},
|
||||
options?: ActionFactoryOptions,
|
||||
): Action | null {
|
||||
if (
|
||||
// Command not intended for this card (e.g. query string command).
|
||||
// `card_id` is a static routing identifier, matched on the raw (template
|
||||
// unrendered) config.
|
||||
action.card_id &&
|
||||
action.card_id !== options?.cardID
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isIfAction(action)) {
|
||||
return new IfAction(context, action, options?.config, options?.triggerData);
|
||||
}
|
||||
|
||||
switch (action.action) {
|
||||
case 'more-info':
|
||||
return new MoreInfoAction(context, action, options?.config);
|
||||
@@ -182,11 +197,12 @@ export class ActionFactory {
|
||||
return new InternalCallbackAction(context, action, options?.config);
|
||||
}
|
||||
|
||||
/* istanbul ignore next: this path cannot be reached -- @preserve */
|
||||
// Reached when the discriminator is not a known action type -- e.g. a
|
||||
// templated `advanced_camera_card_action`, which is classified on the raw
|
||||
// (unrendered) action and so never matches a case.
|
||||
console.warn(
|
||||
`Advanced Camera Card received unknown card action: ${action['advanced_camera_card_action']}`,
|
||||
);
|
||||
/* istanbul ignore next: this path cannot be reached -- @preserve */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConditionsTriggerData } from '../../conditions/types.js';
|
||||
import { TriggerData } from '../../condition-trigger/triggers/types.js';
|
||||
import {
|
||||
ActionConfig,
|
||||
AuxillaryActionConfig,
|
||||
@@ -6,7 +6,18 @@ import {
|
||||
import { AdvancedCameraCardError } from '../../types.js';
|
||||
import { CardActionsAPI } from '../types';
|
||||
|
||||
// Renders a value's templates, returning a rendered copy. Generic so it
|
||||
// preserves the value's type (the one cast lives in the renderer that supplies
|
||||
// it).
|
||||
export type ActionPrepareCallback = <T>(value: T) => T;
|
||||
|
||||
export interface Action {
|
||||
// Prepare this action for execution by rendering its templates against the
|
||||
// current state. The rendered copy is stored separately; the original config
|
||||
// is left intact, so the action stays reusable. Structural actions (`if`)
|
||||
// override this to leave their nested action sequences raw, so those render
|
||||
// per-step when their branch runs.
|
||||
prepare(actionPrepareCallback: ActionPrepareCallback): void;
|
||||
execute(api: CardActionsAPI): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
@@ -14,7 +25,7 @@ export interface Action {
|
||||
export interface ActionsExecutionRequest {
|
||||
actions: ActionConfig[] | ActionConfig;
|
||||
config?: AuxillaryActionConfig;
|
||||
triggerData?: ConditionsTriggerData;
|
||||
triggerData?: TriggerData;
|
||||
}
|
||||
|
||||
export interface ActionsExecutor {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ConditionsManager } from '../conditions/conditions-manager.js';
|
||||
import { ConditionsEvaluationResult } from '../conditions/types.js';
|
||||
import { ConditionEvaluator } from '../condition-trigger/conditions/conditions/types.js';
|
||||
import { createConditionEvaluator } from '../condition-trigger/conditions/factory.js';
|
||||
import { TriggersManager } from '../condition-trigger/triggers/manager.js';
|
||||
import { TriggerData } from '../condition-trigger/triggers/types.js';
|
||||
import { Automation, AutomationActions } from '../config/schema/automations.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import { TemplateRenderer } from './templates/index.js';
|
||||
import { CardAutomationsAPI, TaggedAutomation } from './types.js';
|
||||
|
||||
const MAX_NESTED_AUTOMATION_EXECUTIONS = 10;
|
||||
@@ -9,7 +12,7 @@ const MAX_NESTED_AUTOMATION_EXECUTIONS = 10;
|
||||
export class AutomationsManager {
|
||||
private _api: CardAutomationsAPI;
|
||||
|
||||
private _automations = new Map<TaggedAutomation, ConditionsManager>();
|
||||
private _automations = new Map<TaggedAutomation, TriggersManager>();
|
||||
|
||||
// A counter to avoid infinite loops, increases every time actions are run,
|
||||
// decreases every time actions are complete.
|
||||
@@ -20,28 +23,39 @@ export class AutomationsManager {
|
||||
}
|
||||
|
||||
public deleteAutomations(tag?: unknown) {
|
||||
for (const [automation, conditionManager] of this._automations) {
|
||||
for (const [automation, triggers] of this._automations) {
|
||||
if (automation.tag === tag) {
|
||||
this._automations.delete(automation);
|
||||
conditionManager.destroy();
|
||||
triggers.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public addAutomations(automations: TaggedAutomation[]): void {
|
||||
const context = { templateRenderer: new TemplateRenderer() };
|
||||
for (const automation of automations) {
|
||||
const conditionManager = new ConditionsManager(
|
||||
automation.conditions,
|
||||
const triggers = new TriggersManager(
|
||||
automation.triggers,
|
||||
this._api.getConditionStateManager(),
|
||||
);
|
||||
conditionManager.addListener((result: ConditionsEvaluationResult) =>
|
||||
this._execute(automation, result),
|
||||
|
||||
// The ongoing `conditions:` block is pull-evaluated at trigger time, so
|
||||
// its evaluators are never subscribed and hold no resources to tear down.
|
||||
// They live in the trigger callback and are released when `triggers` is
|
||||
// destroyed.
|
||||
const conditions = (automation.conditions ?? []).map((condition) =>
|
||||
createConditionEvaluator(condition, context),
|
||||
);
|
||||
this._automations.set(automation, conditionManager);
|
||||
triggers.addListener((data) => this._execute(automation, conditions, data));
|
||||
this._automations.set(automation, triggers);
|
||||
}
|
||||
}
|
||||
|
||||
private _execute(automation: Automation, result: ConditionsEvaluationResult): void {
|
||||
private _execute(
|
||||
automation: Automation,
|
||||
conditions: ConditionEvaluator[],
|
||||
triggerData: TriggerData,
|
||||
): void {
|
||||
if (
|
||||
!this._api.getHASSManager().hasHASS() ||
|
||||
// Never execute automations if the card hasn't finished initializing, as
|
||||
@@ -55,17 +69,25 @@ export class AutomationsManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldExecute = result.result;
|
||||
const actions = shouldExecute ? automation.actions : automation.actions_not;
|
||||
// Evaluate the ongoing conditions against the current state at the instant
|
||||
// the automation is triggered. The state manager updates its stored state
|
||||
// before dispatching to listeners, so this already reflects the triggering
|
||||
// change.
|
||||
const state = this._api.getConditionStateManager().getState();
|
||||
const ongoingConditionsHold = conditions.every(
|
||||
(evaluator) => evaluator.evaluate(state).result,
|
||||
);
|
||||
|
||||
if (!actions?.length) {
|
||||
if (!ongoingConditionsHold || !automation.actions.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runActions = async (actions: AutomationActions): Promise<void> => {
|
||||
++this._nestedAutomationExecutions;
|
||||
|
||||
if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) {
|
||||
// Check the limit *before* incrementing, so the overflow path holds no
|
||||
// increment to leak; the `finally` then guarantees the decrement even if
|
||||
// executing the actions throws. Either leak would permanently inflate the
|
||||
// counter and eventually block all automations.
|
||||
if (this._nestedAutomationExecutions >= MAX_NESTED_AUTOMATION_EXECUTIONS) {
|
||||
this._api.getNotificationManager().setNotification({
|
||||
heading: {
|
||||
text: localize('error.too_many_automations'),
|
||||
@@ -76,12 +98,13 @@ export class AutomationsManager {
|
||||
return;
|
||||
}
|
||||
|
||||
await this._api
|
||||
.getActionsManager()
|
||||
.executeActions({ actions, triggerData: result.triggerData });
|
||||
|
||||
--this._nestedAutomationExecutions;
|
||||
++this._nestedAutomationExecutions;
|
||||
try {
|
||||
await this._api.getActionsManager().executeActions({ actions, triggerData });
|
||||
} finally {
|
||||
--this._nestedAutomationExecutions;
|
||||
}
|
||||
};
|
||||
runActions(actions);
|
||||
runActions(automation.actions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createNotificationFromText } from '../../components-lib/notification/factory';
|
||||
import { ConditionStateChange } from '../../conditions/types';
|
||||
import { ConditionStateChange } from '../../condition-trigger/conditions/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { Timer } from '../../utils/timer';
|
||||
import { getStreamCameraID } from '../../view/substream';
|
||||
|
||||
+4
-4
@@ -2,7 +2,7 @@ import { maxBy, throttle } from 'lodash-es';
|
||||
import { CameraEvent } from '../camera-manager/types';
|
||||
import { isTriggeredState } from '../ha/is-triggered-state';
|
||||
import { Timer } from '../utils/timer';
|
||||
import { CardTriggersAPI } from './types';
|
||||
import { CardCameraTriggersAPI } from './types';
|
||||
|
||||
interface CameraTriggerState {
|
||||
// The time of the most recent trigger event. Used to determine the most
|
||||
@@ -26,15 +26,15 @@ interface CameraTriggerState {
|
||||
untriggerForceTimer?: Timer;
|
||||
}
|
||||
|
||||
export class TriggersManager {
|
||||
private _api: CardTriggersAPI;
|
||||
export class CameraTriggersManager {
|
||||
private _api: CardCameraTriggersAPI;
|
||||
private _states: Map<string, CameraTriggerState> = new Map();
|
||||
|
||||
private _throttledTriggerAction = throttle(this._triggerAction.bind(this), 1000, {
|
||||
trailing: true,
|
||||
});
|
||||
|
||||
constructor(api: CardTriggersAPI) {
|
||||
constructor(api: CardCameraTriggersAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ export class CardElementManager {
|
||||
// reconnection, to ensure the state subscription/unsubscription works
|
||||
// correctly and triggers that changed while detached are picked up.
|
||||
// Reset trigger state first to stop stale timers and clear condition state.
|
||||
this._api.getTriggersManager().reset();
|
||||
this._api.getCameraTriggersManager().reset();
|
||||
|
||||
this._api.getCallManager().uninitialize();
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
|
||||
|
||||
@@ -22,9 +22,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
|
||||
const automations: TaggedAutomation[] = [
|
||||
{
|
||||
conditions: [
|
||||
triggers: [
|
||||
{
|
||||
condition: 'config' as const,
|
||||
trigger: 'config' as const,
|
||||
paths: ['cameras', 'remote_control.entities.camera'],
|
||||
},
|
||||
],
|
||||
@@ -38,9 +38,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
tag: automationTag,
|
||||
},
|
||||
{
|
||||
conditions: [
|
||||
triggers: [
|
||||
{
|
||||
condition: 'camera' as const,
|
||||
trigger: 'camera' as const,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
@@ -63,9 +63,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
// this one, as automations only run *after* the card is initialized (and
|
||||
// it very likely will not yet be). Instead, wait to be initialized, then
|
||||
// set the camera.
|
||||
conditions: [
|
||||
triggers: [
|
||||
{
|
||||
condition: 'initialized' as const,
|
||||
trigger: 'initialized' as const,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
@@ -81,15 +81,15 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
tag: automationTag,
|
||||
},
|
||||
{
|
||||
conditions: [
|
||||
triggers: [
|
||||
{
|
||||
condition: 'state' as const,
|
||||
entity: cameraControlEntity,
|
||||
trigger: 'state' as const,
|
||||
entity_id: cameraControlEntity,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
// When the entity state changes, updated the selected option.
|
||||
createCameraAction('{{ advanced_camera_card.trigger.state.to }}'),
|
||||
createCameraAction('{{ trigger.to_state.state }}'),
|
||||
],
|
||||
tag: automationTag,
|
||||
},
|
||||
|
||||
@@ -64,9 +64,9 @@ const convertKeyboardShortcutsToAutomations = (
|
||||
}
|
||||
|
||||
automations.push({
|
||||
conditions: [
|
||||
triggers: [
|
||||
{
|
||||
condition: 'key' as const,
|
||||
trigger: 'key' as const,
|
||||
key: shortcut.key,
|
||||
state: 'down',
|
||||
shift: shortcut.shift,
|
||||
@@ -85,9 +85,9 @@ const convertKeyboardShortcutsToAutomations = (
|
||||
});
|
||||
|
||||
automations.push({
|
||||
conditions: [
|
||||
triggers: [
|
||||
{
|
||||
condition: 'key' as const,
|
||||
trigger: 'key' as const,
|
||||
key: shortcut.key,
|
||||
state: 'up',
|
||||
},
|
||||
@@ -105,9 +105,9 @@ const convertKeyboardShortcutsToAutomations = (
|
||||
const homeShortcut = shortcuts.ptz_home;
|
||||
if (homeShortcut) {
|
||||
automations.push({
|
||||
conditions: [
|
||||
triggers: [
|
||||
{
|
||||
condition: 'key' as const,
|
||||
trigger: 'key' as const,
|
||||
key: homeShortcut.key,
|
||||
state: 'down',
|
||||
shift: homeShortcut.shift,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { get, merge } from 'lodash-es';
|
||||
import { ConditionsManager } from '../../conditions/conditions-manager';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
|
||||
import { ConditionsManager } from '../../condition-trigger/conditions/conditions-manager';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../../condition-trigger/conditions/types';
|
||||
import {
|
||||
copyConfig,
|
||||
deleteConfigValue,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactiveController } from 'lit';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { ConditionStateManager } from '../conditions/state-manager';
|
||||
import { ConditionStateManager } from '../condition-trigger/conditions/state-manager';
|
||||
import { AdvancedCameraCardConfig } from '../config/schema/types';
|
||||
import { DeviceRegistryManager } from '../ha/registry/device';
|
||||
import { DeviceCache } from '../ha/registry/device/types';
|
||||
@@ -11,6 +11,7 @@ import { LovelaceCardEditor } from '../ha/types';
|
||||
import { ActionsManager } from './actions/actions-manager';
|
||||
import { AutomationsManager } from './automations-manager';
|
||||
import { CallManager } from './call/manager';
|
||||
import { CameraTriggersManager } from './camera-triggers-manager';
|
||||
import { CameraURLManager } from './camera-url-manager';
|
||||
import {
|
||||
CardElementManager,
|
||||
@@ -40,11 +41,11 @@ import { QueryStringManager } from './query-string-manager';
|
||||
import { StatusBarItemManager } from './status-bar-item-manager';
|
||||
import { StyleManager } from './style-manager';
|
||||
import { TemplateRenderer } from './templates';
|
||||
import { TriggersManager } from './triggers-manager';
|
||||
import {
|
||||
CardActionsManagerAPI,
|
||||
CardAutomationsAPI,
|
||||
CardCameraAPI,
|
||||
CardCameraTriggersAPI,
|
||||
CardCameraURLAPI,
|
||||
CardConditionAPI,
|
||||
CardConfigAPI,
|
||||
@@ -66,7 +67,6 @@ import {
|
||||
CardPIPAPI,
|
||||
CardQueryStringAPI,
|
||||
CardStyleAPI,
|
||||
CardTriggersAPI,
|
||||
CardViewAPI,
|
||||
} from './types';
|
||||
import { ViewItemManager } from './view/item-manager';
|
||||
@@ -98,7 +98,7 @@ export class CardController
|
||||
CardNotificationAPI,
|
||||
CardQueryStringAPI,
|
||||
CardStyleAPI,
|
||||
CardTriggersAPI,
|
||||
CardCameraTriggersAPI,
|
||||
CardViewAPI,
|
||||
ReactiveController
|
||||
{
|
||||
@@ -137,7 +137,7 @@ export class CardController
|
||||
private _queryStringManager = new QueryStringManager(this);
|
||||
private _statusBarItemManager = new StatusBarItemManager(this);
|
||||
private _styleManager = new StyleManager(this);
|
||||
private _triggersManager = new TriggersManager(this);
|
||||
private _cameraTriggersManager = new CameraTriggersManager(this);
|
||||
private _viewManager = new ViewManager(this);
|
||||
private _viewItemManager = new ViewItemManager(this);
|
||||
|
||||
@@ -305,8 +305,8 @@ export class CardController
|
||||
return this._styleManager;
|
||||
}
|
||||
|
||||
public getTriggersManager(): TriggersManager {
|
||||
return this._triggersManager;
|
||||
public getCameraTriggersManager(): CameraTriggersManager {
|
||||
return this._cameraTriggersManager;
|
||||
}
|
||||
|
||||
public getViewManager(): ViewManager {
|
||||
|
||||
@@ -55,9 +55,9 @@ export class DefaultManager {
|
||||
this._api.getAutomationsManager().addAutomations([
|
||||
{
|
||||
actions: [createGeneralAction('default')],
|
||||
conditions: [
|
||||
triggers: [
|
||||
{
|
||||
condition: 'interaction' as const,
|
||||
trigger: 'interaction' as const,
|
||||
interaction: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionState } from '../../condition-trigger/conditions/types';
|
||||
import { FolderConfig, FolderType, folderTypeSchema } from '../../config/schema/folders';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { Endpoint } from '../../types';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sub } from 'date-fns';
|
||||
import { NonEmptyTuple } from 'type-fest';
|
||||
import { ConditionState } from '../../../conditions/types';
|
||||
import { ConditionState } from '../../../condition-trigger/conditions/types';
|
||||
import {
|
||||
FolderConfig,
|
||||
folderTypeSchema,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sub } from 'date-fns';
|
||||
import { ConditionState } from '../../../conditions/types';
|
||||
import { ConditionState } from '../../../condition-trigger/conditions/types';
|
||||
import {
|
||||
DateMatcher,
|
||||
Matcher,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionState } from '../../condition-trigger/conditions/types';
|
||||
import { FolderConfig, FolderConfigWithoutID } from '../../config/schema/folders';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { hasUnsupportedFilters } from '../../query-source.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NonEmptyTuple } from 'type-fest';
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionState } from '../../condition-trigger/conditions/types';
|
||||
import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
|
||||
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConditionStateChange } from '../../../conditions/types';
|
||||
import { ConditionStateChange } from '../../../condition-trigger/conditions/types';
|
||||
import { WebkitHTMLVideoElement } from '../../../types';
|
||||
import { Timer } from '../../../utils/timer';
|
||||
import { FullscreenProviderBase } from '../provider';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HassEvent } from 'home-assistant-js-websocket';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { KeyedSubscriptionManager } from '../../utils/keyed-subscription-manager';
|
||||
import { KeyedSubscriptionManager } from '../../utils/concurrency/keyed-subscription-manager';
|
||||
|
||||
export interface EventSubscriptionRequest {
|
||||
event_type: string;
|
||||
|
||||
@@ -160,7 +160,7 @@ export class InitializationManager {
|
||||
this._initializer.initializeIfNecessary(
|
||||
InitializationAspect.INITIAL_TRIGGER,
|
||||
async () => {
|
||||
await this._api.getTriggersManager().handleInitialCameraTriggers();
|
||||
await this._api.getCameraTriggersManager().handleInitialCameraTriggers();
|
||||
|
||||
// Force a card update to continue the initialization.
|
||||
this._api.getCardElementManager().update();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CardIssueManagerAPI } from '../types';
|
||||
import { IssueManager } from './issue-manager';
|
||||
import { ConfigErrorIssue } from './issues/config-error';
|
||||
import { ConfigUpgradeIssue } from './issues/config-upgrade';
|
||||
import { ConfigUpgradeFailureIssue } from './issues/config-upgrade-failure';
|
||||
import { ConnectionIssue } from './issues/connection';
|
||||
import { InitializationIssue } from './issues/initialization';
|
||||
import { LegacyResourceIssue } from './issues/legacy-resource';
|
||||
@@ -19,6 +20,7 @@ export const createIssueManager = (api: CardIssueManagerAPI): IssueManager => {
|
||||
// full-card issue. Register broader/more critical issues first.
|
||||
manager.addIssue(new ConfigErrorIssue());
|
||||
manager.addIssue(new ConfigUpgradeIssue(api));
|
||||
manager.addIssue(new ConfigUpgradeFailureIssue(api));
|
||||
manager.addIssue(new ViewIncompatibleIssue(api));
|
||||
manager.addIssue(new ConnectionIssue());
|
||||
manager.addIssue(new InitializationIssue(api));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IssueTriggerContext } from 'issue';
|
||||
import { ConditionStateChange } from '../../conditions/types';
|
||||
import { ConditionStateChange } from '../../condition-trigger/conditions/types';
|
||||
import { isActionAllowedBasedOnInteractionState } from '../../utils/interaction-mode';
|
||||
import { Timer } from '../../utils/timer';
|
||||
import { CardIssueManagerAPI } from '../types';
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { hasConfigUpgradeFailures } from '../../../config/management.js';
|
||||
import { TROUBLESHOOTING_CONFIG_UPGRADE_FAILURE_URL } from '../../../const.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
import { CardIssueManagerAPI } from '../../types';
|
||||
import { Issue, IssueDescription } from '../types';
|
||||
|
||||
// Raised when the configuration upgrade could not faithfully upgrade part of
|
||||
// the config.
|
||||
export class ConfigUpgradeFailureIssue implements Issue {
|
||||
public readonly key = 'config_upgrade_failure' as const;
|
||||
|
||||
private _api: CardIssueManagerAPI;
|
||||
private _hasFailure = false;
|
||||
|
||||
constructor(api: CardIssueManagerAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public async detectStatic(): Promise<void> {
|
||||
this._hasFailure = hasConfigUpgradeFailures(
|
||||
this._api.getConfigManager().getRawConfig(),
|
||||
);
|
||||
}
|
||||
|
||||
public hasIssue(): boolean {
|
||||
return this._hasFailure;
|
||||
}
|
||||
|
||||
public getIssue(): IssueDescription | null {
|
||||
if (!this._hasFailure) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
icon: 'mdi:update',
|
||||
severity: 'medium',
|
||||
notification: {
|
||||
heading: {
|
||||
text: localize('issues.config_upgrade_failure.heading'),
|
||||
icon: 'mdi:update',
|
||||
severity: 'medium',
|
||||
},
|
||||
body: { text: localize('issues.config_upgrade_failure.text') },
|
||||
link: {
|
||||
url: TROUBLESHOOTING_CONFIG_UPGRADE_FAILURE_URL,
|
||||
title: localize('issues.troubleshooting_guide'),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { STATE_RUNNING } from 'home-assistant-js-websocket';
|
||||
import { ConditionState } from '../../../conditions/types.js';
|
||||
import { ConditionState } from '../../../condition-trigger/conditions/types.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
import { Issue, IssueDescription } from '../types.js';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IssueTriggerContext } from 'issue';
|
||||
import { ConditionState } from '../../../conditions/types.js';
|
||||
import { ConditionState } from '../../../condition-trigger/conditions/types.js';
|
||||
import { Notification } from '../../../config/schema/actions/types.js';
|
||||
import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IssueTriggerContext } from 'issue';
|
||||
import { summarizeNotification } from '../../components-lib/notification/summarize';
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionState } from '../../condition-trigger/conditions/types';
|
||||
import { Notification } from '../../config/schema/actions/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { isTruthy } from '../../utils/basic';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IssueTriggerContext } from 'issue';
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { ConditionState } from '../../condition-trigger/conditions/types';
|
||||
import { Notification } from '../../config/schema/actions/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { Severity } from '../../severity';
|
||||
@@ -7,6 +7,7 @@ import { Severity } from '../../severity';
|
||||
export type IssueKey =
|
||||
| 'config_error'
|
||||
| 'config_upgrade'
|
||||
| 'config_upgrade_failure'
|
||||
| 'connection'
|
||||
| 'initialization'
|
||||
| 'legacy_resource'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConditionStateChange } from '../conditions/types';
|
||||
import { ConditionStateChange } from '../condition-trigger/conditions/types';
|
||||
import { PIPElement } from '../types';
|
||||
import { CardPIPAPI } from './types';
|
||||
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
import { HASS, renderTemplate } from 'ha-nunjucks/dist';
|
||||
import { ConditionState, ConditionsTriggerData } from '../../conditions/types';
|
||||
import { ConditionState } from '../../condition-trigger/conditions/types';
|
||||
import { TriggerData } from '../../condition-trigger/triggers/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
|
||||
interface TemplateMediaData {
|
||||
title: string;
|
||||
is_folder: boolean;
|
||||
}
|
||||
interface TemplateContextInternal {
|
||||
camera?: string;
|
||||
view?: string;
|
||||
trigger?: ConditionsTriggerData;
|
||||
media?: TemplateMediaData;
|
||||
}
|
||||
import { isRecord } from '../../utils/basic';
|
||||
import { TemplateACCNamespace, TemplateMediaData } from './types';
|
||||
|
||||
interface TemplateContext {
|
||||
advanced_camera_card: TemplateContextInternal;
|
||||
acc: TemplateACCNamespace;
|
||||
|
||||
// Convenient alias.
|
||||
acc: TemplateContextInternal;
|
||||
// The HA-native top-level `trigger`, set only when a trigger fired.
|
||||
trigger?: TriggerData;
|
||||
}
|
||||
|
||||
interface TemplateRenderOptions {
|
||||
conditionState?: ConditionState;
|
||||
triggerData?: ConditionsTriggerData;
|
||||
triggerData?: TriggerData;
|
||||
mediaData?: TemplateMediaData;
|
||||
}
|
||||
|
||||
@@ -45,22 +37,23 @@ export class TemplateRenderer {
|
||||
if (
|
||||
!options?.conditionState?.camera &&
|
||||
!options?.conditionState?.view &&
|
||||
!options?.conditionState?.config &&
|
||||
!options?.triggerData &&
|
||||
!options?.mediaData
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const advancedCameraCardContext: TemplateContextInternal = {
|
||||
const acc: TemplateACCNamespace = {
|
||||
...(options?.conditionState?.camera && { camera: options.conditionState.camera }),
|
||||
...(options?.conditionState?.view && { view: options.conditionState.view }),
|
||||
...(options?.triggerData && { trigger: options.triggerData }),
|
||||
...(options?.conditionState?.config && { config: options.conditionState.config }),
|
||||
...(options?.mediaData && { media: options.mediaData }),
|
||||
};
|
||||
|
||||
return {
|
||||
acc: advancedCameraCardContext,
|
||||
advanced_camera_card: advancedCameraCardContext,
|
||||
acc,
|
||||
...(options?.triggerData && { trigger: options.triggerData }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,7 +74,7 @@ export class TemplateRenderer {
|
||||
return data.map((item) =>
|
||||
this._renderTemplateRecursively(hass, item, templateContext),
|
||||
);
|
||||
} else if (typeof data === 'object' && data !== null) {
|
||||
} else if (isRecord(data)) {
|
||||
const result = {};
|
||||
for (const key in data) {
|
||||
result[key] = this._renderTemplateRecursively(hass, data[key], templateContext);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { AdvancedCameraCardConfig } from '../../config/schema/types';
|
||||
|
||||
// The card state exposed via the `acc` namespace AND as the
|
||||
// `trigger.from_acc`/`to_acc` before/after snapshots (the card analogue of HA's
|
||||
// full `trigger.from_state`/`to_state`).
|
||||
export interface TemplateAdvancedCameraCardState {
|
||||
camera?: string;
|
||||
view?: string;
|
||||
config?: AdvancedCameraCardConfig;
|
||||
}
|
||||
|
||||
export interface TemplateMediaData {
|
||||
title: string;
|
||||
is_folder: boolean;
|
||||
}
|
||||
|
||||
// The ambient `acc` namespace: card state plus `media` (the item currently being
|
||||
// templated, e.g. a folder-match candidate). `media` is not card state, so it is
|
||||
// not part of the shared snapshot type above.
|
||||
export interface TemplateACCNamespace extends TemplateAdvancedCameraCardState {
|
||||
media?: TemplateMediaData;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CameraManager } from '../camera-manager/manager';
|
||||
import type { ConditionStateManager } from '../conditions/state-manager';
|
||||
import type { ConditionStateManager } from '../condition-trigger/conditions/state-manager';
|
||||
import type { Automation } from '../config/schema/automations';
|
||||
import type { DeviceRegistryManager } from '../ha/registry/device';
|
||||
import type { EntityRegistryManager } from '../ha/registry/entity/types';
|
||||
@@ -8,6 +8,7 @@ import type { EffectsManagerInterface } from '../types';
|
||||
import type { ActionsManager } from './actions/actions-manager';
|
||||
import type { AutomationsManager } from './automations-manager';
|
||||
import type { CallManager } from './call/manager';
|
||||
import type { CameraTriggersManager } from './camera-triggers-manager';
|
||||
import type { CameraURLManager } from './camera-url-manager';
|
||||
import type { CardElementManager } from './card-element-manager';
|
||||
import type { ConfigManager } from './config/config-manager';
|
||||
@@ -29,7 +30,6 @@ import type { PIPManager } from './pip-manager';
|
||||
import type { QueryStringManager } from './query-string-manager';
|
||||
import type { StatusBarItemManager } from './status-bar-item-manager';
|
||||
import type { StyleManager } from './style-manager';
|
||||
import type { TriggersManager } from './triggers-manager';
|
||||
import type { ViewItemManager } from './view/item-manager';
|
||||
import type { ViewManager } from './view/view-manager';
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface CardActionsAPI {
|
||||
getPIPManager(): PIPManager;
|
||||
getIssueManager(): IssueManager;
|
||||
getStatusBarItemManager(): StatusBarItemManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
getCameraTriggersManager(): CameraTriggersManager;
|
||||
getViewItemManager(): ViewItemManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export interface CardCameraAPI {
|
||||
getEntityRegistryManager(): EntityRegistryManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getResolvedMediaCache(): ResolvedMediaCache;
|
||||
getTriggersManager(): TriggersManager;
|
||||
getCameraTriggersManager(): CameraTriggersManager;
|
||||
}
|
||||
|
||||
export interface CardCameraURLAPI {
|
||||
@@ -139,7 +139,7 @@ export interface CardDefaultManagerAPI {
|
||||
getConfigManager(): ConfigManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getInteractionManager(): InteractionManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
getCameraTriggersManager(): CameraTriggersManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ export interface CardElementAPI {
|
||||
getPIPManager(): PIPManager;
|
||||
getIssueManager(): IssueManager;
|
||||
getQueryStringManager(): QueryStringManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
getCameraTriggersManager(): CameraTriggersManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ export interface CardHASSAPI {
|
||||
getInteractionManager(): InteractionManager;
|
||||
getMediaPlayerManager(): MediaPlayerManager;
|
||||
getStyleManager(): StyleManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
getCameraTriggersManager(): CameraTriggersManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ export interface CardInitializerAPI {
|
||||
getIssueManager(): IssueManager;
|
||||
getQueryStringManager(): QueryStringManager;
|
||||
getResolvedMediaCache(): ResolvedMediaCache;
|
||||
getTriggersManager(): TriggersManager;
|
||||
getCameraTriggersManager(): CameraTriggersManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ export interface CardInteractionAPI {
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getStyleManager(): StyleManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
getCameraTriggersManager(): CameraTriggersManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ export interface CardStyleAPI {
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
export interface CardTriggersAPI {
|
||||
export interface CardCameraTriggersAPI {
|
||||
getCallManager(): CallManager;
|
||||
getCameraManager(): CameraManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
@@ -339,7 +339,7 @@ export interface CardViewAPI {
|
||||
getIssueManager(): IssueManager;
|
||||
getQueryStringManager(): QueryStringManager;
|
||||
getStyleManager(): StyleManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
getCameraTriggersManager(): CameraTriggersManager;
|
||||
}
|
||||
|
||||
// *************************************************************************
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ import './components/status-bar';
|
||||
import './components/thumbnail-carousel.js';
|
||||
import './components/views.js';
|
||||
import { AdvancedCameraCardViews } from './components/views.js';
|
||||
import { ConditionStateManagerGetEvent } from './conditions/state-manager-via-event.js';
|
||||
import { ConditionStateManagerGetEvent } from './condition-trigger/conditions/state-manager-via-event.js';
|
||||
import { StatusBarItem } from './config/schema/actions/types.js';
|
||||
import { MenuItem } from './config/schema/elements/custom/menu/types.js';
|
||||
import { AdvancedCameraCardConfig } from './config/schema/types.js';
|
||||
@@ -457,7 +457,7 @@ class AdvancedCameraCard extends LitElement {
|
||||
.locked=${this._controller.getLockManager().isLocked()}
|
||||
.conditionStateManager=${this._controller.getConditionStateManager()}
|
||||
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
|
||||
? this._controller.getTriggersManager().getTriggeredCameraIDs()
|
||||
? this._controller.getCameraTriggersManager().getTriggeredCameraIDs()
|
||||
: undefined}
|
||||
.deviceRegistryManager=${this._controller.getDeviceRegistryManager()}
|
||||
.issues=${this._controller
|
||||
|
||||
@@ -8,7 +8,7 @@ import { MENU_PRIORITY_MAX } from '../config/schema/common/const.js';
|
||||
import type { MenuItem } from '../config/schema/elements/custom/menu/types.js';
|
||||
import type { MenuConfig } from '../config/schema/menu.js';
|
||||
import type { Interaction } from '../types.js';
|
||||
import { getActionConfigGivenAction } from '../utils/action';
|
||||
import { getActionConfigGivenAction, isStandardAction } from '../utils/action';
|
||||
import { arrayify, isTruthy } from '../utils/basic.js';
|
||||
import { AutoHideState, isAutoHidden as evaluateAutoHidden } from './auto-hide.js';
|
||||
|
||||
@@ -225,6 +225,7 @@ export class MenuController {
|
||||
|
||||
private _isMenuToggleAction(action: ActionConfig): boolean {
|
||||
return (
|
||||
isStandardAction(action) &&
|
||||
action.action === 'fire-dom-event' &&
|
||||
action.advanced_camera_card_action === 'menu_toggle'
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ import { FoldersManager } from '../../card-controller/folders/manager';
|
||||
import { ViewItemManager } from '../../card-controller/view/item-manager';
|
||||
import { MergeContextViewModifier } from '../../card-controller/view/modifiers/merge-context';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../../condition-trigger/conditions/types';
|
||||
import { CameraConfig } from '../../config/schema/cameras';
|
||||
import { AdvancedCameraCardView } from '../../config/schema/common/const';
|
||||
import { ThumbnailsControlBaseConfig } from '../../config/schema/common/controls/thumbnails';
|
||||
|
||||
@@ -11,7 +11,7 @@ import { RecordingSegment } from '../../camera-manager/types';
|
||||
import { capEndDate } from '../../camera-manager/utils/cap-end-date';
|
||||
import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly';
|
||||
import { FoldersManager } from '../../card-controller/folders/manager';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../../condition-trigger/conditions/types';
|
||||
import { FolderConfig } from '../../config/schema/folders';
|
||||
import { errorToConsole } from '../../utils/basic.js';
|
||||
import { ViewItem, ViewMedia } from '../../view/item';
|
||||
|
||||
@@ -10,9 +10,9 @@ import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { IssueTriggerEventData } from '../card-controller/issues/types.js';
|
||||
import { TemplateRenderer } from '../card-controller/templates/index.js';
|
||||
import { ConditionsManager } from '../conditions/conditions-manager.js';
|
||||
import { getConditionStateManagerViaEvent } from '../conditions/state-manager-via-event.js';
|
||||
import { ConditionStateManager } from '../conditions/state-manager.js';
|
||||
import { ConditionsManager } from '../condition-trigger/conditions/conditions-manager.js';
|
||||
import { getConditionStateManagerViaEvent } from '../condition-trigger/conditions/state-manager-via-event.js';
|
||||
import { ConditionStateManager } from '../condition-trigger/conditions/state-manager.js';
|
||||
import {
|
||||
StatusBarIcon,
|
||||
StatusBarImage,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
navigateToMedia,
|
||||
navigateUp,
|
||||
} from '../../components-lib/navigation.js';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types.js';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../../condition-trigger/conditions/types.js';
|
||||
import { MediaGalleryConfig } from '../../config/schema/media-gallery.js';
|
||||
import { CardWideConfig } from '../../config/schema/types.js';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const.js';
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { FoldersManager } from '../card-controller/folders/manager.js';
|
||||
import { ViewItemManager } from '../card-controller/view/item-manager.js';
|
||||
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types.js';
|
||||
import { ThumbnailsControlConfig } from '../config/schema/common/controls/thumbnails.js';
|
||||
import { MiniTimelineControlConfig } from '../config/schema/common/controls/timeline.js';
|
||||
import { CardWideConfig } from '../config/schema/types.js';
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
ThumbnailDataRequestEvent,
|
||||
TimelineItemClickAction,
|
||||
} from '../components-lib/timeline/types';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types';
|
||||
import { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails';
|
||||
import { TimelineCoreConfig } from '../config/schema/common/controls/timeline';
|
||||
import { CardWideConfig } from '../config/schema/types';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CameraManager } from '../camera-manager/manager';
|
||||
import { FoldersManager } from '../card-controller/folders/manager';
|
||||
import { ViewItemManager } from '../card-controller/view/item-manager';
|
||||
import { ViewManagerEpoch } from '../card-controller/view/types';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../conditions/types';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types';
|
||||
import { TimelineConfig } from '../config/schema/timeline';
|
||||
import { CardWideConfig } from '../config/schema/types';
|
||||
import { HomeAssistant } from '../ha/types';
|
||||
|
||||
@@ -16,7 +16,7 @@ import { MicrophoneState } from '../card-controller/types.js';
|
||||
import { ViewItemManager } from '../card-controller/view/item-manager.js';
|
||||
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
||||
import { CallSession } from '../card-controller/call/types.js';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types.js';
|
||||
import { AdvancedCameraCardConfig, CardWideConfig } from '../config/schema/types.js';
|
||||
import { RawAdvancedCameraCardConfig } from '../config/types.js';
|
||||
import { DeviceRegistryManager } from '../ha/registry/device/index.js';
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -1,16 +1,17 @@
|
||||
import { CallBase } from '../../../config/schema/condition-trigger/common/call';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator, ConditionOfType } from './types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class CallConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ConditionOfType<'call'>;
|
||||
private _condition: CallBase;
|
||||
|
||||
constructor(condition: ConditionOfType<'call'>) {
|
||||
constructor(condition: CallBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return {
|
||||
result: (this._condition.call ?? true) === (newState?.call ?? false),
|
||||
result: this._condition.call === (newState?.call ?? false),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CameraBase } from '../../../config/schema/condition-trigger/common/camera';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class CameraConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: CameraBase;
|
||||
|
||||
constructor(condition: CameraBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
const camera = newState?.camera;
|
||||
const cameras = this._condition.cameras;
|
||||
if (cameras === undefined) {
|
||||
// Omitted: a camera is selected.
|
||||
return { result: !!camera };
|
||||
}
|
||||
if (cameras.length === 0) {
|
||||
// `[]`: no camera is selected.
|
||||
return { result: !camera };
|
||||
}
|
||||
// A list: the selected camera is one of these.
|
||||
return { result: !!camera && cameras.includes(camera) };
|
||||
}
|
||||
}
|
||||
+5
-8
@@ -1,9 +1,10 @@
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator, ConditionEvaluatorSubscriptionCallback } from './types';
|
||||
import { ConditionEvaluator, ExternalInvalidationSource } from './types';
|
||||
|
||||
/**
|
||||
* Base class for the `or`/`and`/`not` composites: each holds child evaluators
|
||||
* and forwards subscription/teardown to them. Subclasses provide `evaluate`.
|
||||
* and unions their external invalidation sources. Subclasses provide
|
||||
* `evaluate`.
|
||||
*/
|
||||
export abstract class CompositeConditionEvaluator implements ConditionEvaluator {
|
||||
protected _children: ConditionEvaluator[];
|
||||
@@ -17,11 +18,7 @@ export abstract class CompositeConditionEvaluator implements ConditionEvaluator
|
||||
oldState?: ConditionState,
|
||||
): ConditionsEvaluationResult;
|
||||
|
||||
public subscribe(onChange: ConditionEvaluatorSubscriptionCallback): void {
|
||||
this._children.forEach((child) => child.subscribe?.(onChange));
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._children.forEach((child) => child.destroy?.());
|
||||
public get externalSources(): ExternalInvalidationSource[] {
|
||||
return this._children.flatMap((child) => child.externalSources ?? []);
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -1,10 +1,11 @@
|
||||
import { DisplayModeBase } from '../../../config/schema/condition-trigger/common/display-mode';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator, ConditionOfType } from './types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class DisplayModeConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ConditionOfType<'display_mode'>;
|
||||
private _condition: DisplayModeBase;
|
||||
|
||||
constructor(condition: ConditionOfType<'display_mode'>) {
|
||||
constructor(condition: DisplayModeBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,10 +1,11 @@
|
||||
import { ExpandBase } from '../../../config/schema/condition-trigger/common/expand';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator, ConditionOfType } from './types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class ExpandConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ConditionOfType<'expand'>;
|
||||
private _condition: ExpandBase;
|
||||
|
||||
constructor(condition: ConditionOfType<'expand'>) {
|
||||
constructor(condition: ExpandBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,10 +1,11 @@
|
||||
import { FullscreenBase } from '../../../config/schema/condition-trigger/common/fullscreen';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator, ConditionOfType } from './types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class FullscreenConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ConditionOfType<'fullscreen'>;
|
||||
private _condition: FullscreenBase;
|
||||
|
||||
constructor(condition: ConditionOfType<'fullscreen'>) {
|
||||
constructor(condition: FullscreenBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,10 +1,11 @@
|
||||
import { InteractionBase } from '../../../config/schema/condition-trigger/common/interaction';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator, ConditionOfType } from './types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class InteractionConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ConditionOfType<'interaction'>;
|
||||
private _condition: InteractionBase;
|
||||
|
||||
constructor(condition: ConditionOfType<'interaction'>) {
|
||||
constructor(condition: InteractionBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Whether a rendered template result counts as true for a CONDITION. HA's
|
||||
// `condition.py` `async_template` does `value.lower() == "true"`, so only
|
||||
// `true` (case-insensitive) passes -- `yes`/`on`/`1` do NOT (unlike a trigger).
|
||||
// The card's renderer returns native types, so the boolean a comparison
|
||||
// template (e.g. `{{ a == b }}`) produces stringifies to "true" and also
|
||||
// passes.
|
||||
export const isTemplateTrue = (value: unknown): boolean =>
|
||||
String(value).toLowerCase() === 'true';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { KeyBase } from '../../../config/schema/condition-trigger/common/key';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class KeyConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: KeyBase;
|
||||
|
||||
constructor(condition: KeyBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
const condition = this._condition;
|
||||
|
||||
// The condition schema requires `key`; the undefined check only guards the
|
||||
// shared base type, on which it is declared optional.
|
||||
if (condition.key === undefined || !newState?.keys?.[condition.key]) {
|
||||
return { result: false };
|
||||
}
|
||||
const pressed = newState.keys[condition.key];
|
||||
return {
|
||||
result:
|
||||
(condition.state ?? 'down') === pressed.state &&
|
||||
(condition.ctrl === undefined || condition.ctrl === !!pressed.ctrl) &&
|
||||
(condition.alt === undefined || condition.alt === !!pressed.alt) &&
|
||||
(condition.meta === undefined || condition.meta === !!pressed.meta) &&
|
||||
(condition.shift === undefined || condition.shift === !!pressed.shift),
|
||||
};
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -1,10 +1,11 @@
|
||||
import { MediaLoadedBase } from '../../../config/schema/condition-trigger/common/media-loaded';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator, ConditionOfType } from './types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class MediaLoadedConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ConditionOfType<'media_loaded'>;
|
||||
private _condition: MediaLoadedBase;
|
||||
|
||||
constructor(condition: ConditionOfType<'media_loaded'>) {
|
||||
constructor(condition: MediaLoadedBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,10 +1,11 @@
|
||||
import { MicrophoneBase } from '../../../config/schema/condition-trigger/common/microphone';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator, ConditionOfType } from './types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class MicrophoneConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ConditionOfType<'microphone'>;
|
||||
private _condition: MicrophoneBase;
|
||||
|
||||
constructor(condition: ConditionOfType<'microphone'>) {
|
||||
constructor(condition: MicrophoneBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
+7
-5
@@ -2,14 +2,16 @@ import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { CompositeConditionEvaluator } from './composite';
|
||||
|
||||
export class NotConditionEvaluator extends CompositeConditionEvaluator {
|
||||
// "Not" is an inverted `or` (NOR). There is no trigger data for "not
|
||||
// triggering".
|
||||
// "Not" is an inverted `or` (NOR): true when no child matches.
|
||||
public evaluate(
|
||||
newState?: ConditionState,
|
||||
oldState?: ConditionState,
|
||||
): ConditionsEvaluationResult {
|
||||
return {
|
||||
result: !this._children.some((child) => child.evaluate(newState, oldState).result),
|
||||
};
|
||||
for (const child of this._children) {
|
||||
if (child.evaluate(newState, oldState).result) {
|
||||
return { result: false };
|
||||
}
|
||||
}
|
||||
return { result: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { arrayify } from '../../../utils/basic';
|
||||
import { matchesNumericState } from '../../common/numeric-state';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './types';
|
||||
|
||||
export class NumericStateConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ConditionOfType<'numeric_state'>;
|
||||
private _context: EvaluatorContext;
|
||||
|
||||
constructor(condition: ConditionOfType<'numeric_state'>, context: EvaluatorContext) {
|
||||
this._condition = condition;
|
||||
this._context = context;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
const condition = this._condition;
|
||||
|
||||
// `entity` is canonical; `entity_id` is the accepted automation-dialect alias.
|
||||
// Either may be a list; with multiple entities all must match (HA's `match: all`).
|
||||
const entityIDs = arrayify(condition.entity ?? condition.entity_id);
|
||||
if (!entityIDs.length || !newState) {
|
||||
return { result: false };
|
||||
}
|
||||
|
||||
return {
|
||||
result: entityIDs.every((entityID) =>
|
||||
matchesNumericState(
|
||||
entityID,
|
||||
newState,
|
||||
condition,
|
||||
this._context.templateRenderer,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,8 @@ export class OrConditionEvaluator extends CompositeConditionEvaluator {
|
||||
oldState?: ConditionState,
|
||||
): ConditionsEvaluationResult {
|
||||
for (const child of this._children) {
|
||||
const evaluation = child.evaluate(newState, oldState);
|
||||
if (evaluation.result) {
|
||||
return evaluation;
|
||||
if (child.evaluate(newState, oldState).result) {
|
||||
return { result: true };
|
||||
}
|
||||
}
|
||||
return { result: false };
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ScreenBase } from '../../../config/schema/condition-trigger/common/screen';
|
||||
import { MediaQueryWatcher } from '../../common/media-query-watcher';
|
||||
import { ConditionsEvaluationResult } from '../types';
|
||||
import { ConditionEvaluator, ExternalInvalidationSource } from './types';
|
||||
|
||||
export class ScreenConditionEvaluator implements ConditionEvaluator {
|
||||
private _watcher: MediaQueryWatcher | null;
|
||||
|
||||
constructor(condition: ScreenBase) {
|
||||
this._watcher = condition.media_query
|
||||
? new MediaQueryWatcher(condition.media_query)
|
||||
: null;
|
||||
}
|
||||
|
||||
public evaluate(): ConditionsEvaluationResult {
|
||||
return { result: this._watcher?.matches() ?? false };
|
||||
}
|
||||
|
||||
public get externalSources(): ExternalInvalidationSource[] {
|
||||
return this._watcher ? [this._watcher] : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { arrayify } from '../../../utils/basic';
|
||||
import { renderTimePeriodToSeconds } from '../../common/time-period';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './types';
|
||||
|
||||
// Resolve each expected value that names an entity present in `hass` to that
|
||||
// entity's current state, accepting either the literal or the resolved value
|
||||
// (i.e. HA's Lovelace state condition will resolve "state: input_boolean.foo"
|
||||
// to "state: on" when input_boolean.foo is on).
|
||||
const resolveExpectedStates = (
|
||||
values: string | string[],
|
||||
state?: ConditionState,
|
||||
): string[] =>
|
||||
// Cannot use `arrayify` as an empty-string expected value is a real value.
|
||||
(Array.isArray(values) ? values : [values]).flatMap((value) => {
|
||||
const resolved = state?.hass?.states?.[value]?.state;
|
||||
return resolved !== undefined ? [value, resolved] : [value];
|
||||
});
|
||||
|
||||
export class StateConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ConditionOfType<'state'>;
|
||||
private _context: EvaluatorContext;
|
||||
|
||||
constructor(condition: ConditionOfType<'state'>, context: EvaluatorContext) {
|
||||
this._condition = condition;
|
||||
this._context = context;
|
||||
}
|
||||
|
||||
public evaluate(
|
||||
newState?: ConditionState,
|
||||
oldState?: ConditionState,
|
||||
): ConditionsEvaluationResult {
|
||||
const condition = this._condition;
|
||||
|
||||
// `entity` is canonical; `entity_id` is the accepted automation-dialect alias.
|
||||
// Either may be a list; with multiple entities all must match (HA's `match: all`).
|
||||
const entityIDs = arrayify(condition.entity ?? condition.entity_id);
|
||||
if (!entityIDs.length) {
|
||||
return { result: false };
|
||||
}
|
||||
|
||||
// The compared value is the attribute when `attribute` is set, else the state.
|
||||
const readValue = (entityID: string, state?: ConditionState): string | null => {
|
||||
const stateObj = state?.hass?.states?.[entityID];
|
||||
if (!stateObj) {
|
||||
return null;
|
||||
}
|
||||
if (condition.attribute) {
|
||||
const value = stateObj.attributes?.[condition.attribute];
|
||||
return value === undefined || value === null ? null : String(value);
|
||||
}
|
||||
return stateObj.state;
|
||||
};
|
||||
|
||||
const matchesEntity = (entityID: string): boolean => {
|
||||
const fromValue = readValue(entityID, oldState);
|
||||
const toValue = readValue(entityID, newState);
|
||||
|
||||
let result: boolean;
|
||||
if (condition.state === undefined && condition.state_not === undefined) {
|
||||
// With neither `state` nor `state_not`, match any change of value.
|
||||
result = toValue !== fromValue;
|
||||
} else if (toValue === null) {
|
||||
// A missing entity or attribute cannot match; an empty-string state is
|
||||
// a real value, handled in the comparison below.
|
||||
result = false;
|
||||
} else {
|
||||
result =
|
||||
(condition.state === undefined ||
|
||||
resolveExpectedStates(condition.state, newState).includes(toValue)) &&
|
||||
(condition.state_not === undefined ||
|
||||
!resolveExpectedStates(condition.state_not, newState).includes(toValue));
|
||||
}
|
||||
|
||||
// `for`: the match must have been held for at least the given duration.
|
||||
// Evaluated against `last_changed` at evaluation time (correct for the
|
||||
// point-in-time / ongoing-condition use).
|
||||
if (result && condition.for !== undefined) {
|
||||
const forSeconds = renderTimePeriodToSeconds(
|
||||
this._context.templateRenderer,
|
||||
condition.for,
|
||||
newState,
|
||||
);
|
||||
const lastChanged = newState?.hass?.states?.[entityID]?.last_changed;
|
||||
if (forSeconds === null || !lastChanged) {
|
||||
result = false;
|
||||
} else {
|
||||
const heldSeconds =
|
||||
(new Date().getTime() - new Date(lastChanged).getTime()) / 1000;
|
||||
result = heldSeconds >= forSeconds;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// `match: any` requires one entity to match; `all` (the default) requires all.
|
||||
const result =
|
||||
condition.match === 'any'
|
||||
? entityIDs.some(matchesEntity)
|
||||
: entityIDs.every(matchesEntity);
|
||||
|
||||
return { result };
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -1,4 +1,5 @@
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { isTemplateTrue } from './is-template-true';
|
||||
import { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './types';
|
||||
|
||||
export class TemplateConditionEvaluator implements ConditionEvaluator {
|
||||
@@ -14,11 +15,13 @@ export class TemplateConditionEvaluator implements ConditionEvaluator {
|
||||
return {
|
||||
result:
|
||||
!!newState?.hass &&
|
||||
this._context.templateRenderer.renderRecursively(
|
||||
newState.hass,
|
||||
this._condition.value_template,
|
||||
{ conditionState: newState },
|
||||
) === true,
|
||||
isTemplateTrue(
|
||||
this._context.templateRenderer.renderRecursively(
|
||||
newState.hass,
|
||||
this._condition.value_template,
|
||||
{ conditionState: newState },
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { TriggeredBase } from '../../../config/schema/condition-trigger/common/triggered';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class TriggeredConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: TriggeredBase;
|
||||
|
||||
constructor(condition: TriggeredBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
const active = newState?.triggered;
|
||||
const cameraIDs = this._condition.triggered;
|
||||
const count = active?.size ?? 0;
|
||||
|
||||
let result: boolean;
|
||||
if (cameraIDs === undefined) {
|
||||
// Omitted: any camera is triggered.
|
||||
result = count > 0;
|
||||
} else if (cameraIDs.length === 0) {
|
||||
// `[]`: no camera is triggered.
|
||||
result = count === 0;
|
||||
} else {
|
||||
// A list: one of the named cameras is among those triggered.
|
||||
result = !!active && cameraIDs.some((cameraID) => active.has(cameraID));
|
||||
}
|
||||
return { result };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TemplateRenderer } from '../../../card-controller/templates';
|
||||
import { Condition } from '../../../config/schema/condition-trigger/conditions/types';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
|
||||
export type ExternalInvalidationUnsubscribeCallback = () => void;
|
||||
|
||||
// A source of change outside the card's `ConditionState` that can invalidate a
|
||||
// condition's result (currently only `screen`, via `matchMedia`). A condition
|
||||
// declares its sources so a reactive consumer knows what to watch; a pull
|
||||
// consumer ignores them.
|
||||
export interface ExternalInvalidationSource {
|
||||
subscribe(callback: () => void): ExternalInvalidationUnsubscribeCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single condition, constructed once with its configuration and evaluated
|
||||
* repeatedly against incoming state.
|
||||
*/
|
||||
export interface ConditionEvaluator {
|
||||
evaluate(
|
||||
newState?: ConditionState,
|
||||
oldState?: ConditionState,
|
||||
): ConditionsEvaluationResult;
|
||||
|
||||
// Sources of change outside `ConditionState` that can invalidate this
|
||||
// condition's result (currently only `screen`, via `matchMedia`). A reactive
|
||||
// consumer subscribes to them to know when to re-evaluate; a pull consumer
|
||||
// ignores them.
|
||||
externalSources?: ExternalInvalidationSource[];
|
||||
}
|
||||
|
||||
export interface EvaluatorContext {
|
||||
templateRenderer: TemplateRenderer;
|
||||
}
|
||||
|
||||
// The condition union member(s) carrying a given discriminator literal.
|
||||
export type ConditionOfType<T extends string> = Extract<Condition, { condition?: T }>;
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { isBeingCasted } from '../../utils/casting';
|
||||
import { isCompanionApp } from '../../utils/companion';
|
||||
import { isBeingCasted } from '../../../utils/casting';
|
||||
import { isCompanionApp } from '../../../utils/companion';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator, ConditionOfType } from './types';
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@ export class UserConditionEvaluator implements ConditionEvaluator {
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return {
|
||||
result:
|
||||
!!newState?.hass?.user && this._condition.users.includes(newState.hass.user.id),
|
||||
!!newState?.hass?.user &&
|
||||
!!this._condition.users?.includes(newState.hass.user.id),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ViewBase } from '../../../config/schema/condition-trigger/common/view';
|
||||
import { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import { ConditionEvaluator } from './types';
|
||||
|
||||
export class ViewConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: ViewBase;
|
||||
|
||||
constructor(condition: ViewBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
const view = newState?.view;
|
||||
return {
|
||||
// The condition schema requires `views`; the optional access only guards
|
||||
// the shared base type, on which it is declared optional.
|
||||
result: !!view && !!this._condition.views?.includes(view),
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user