feat: Trigger automations on call answered, rejected and hung up (#2591)
This commit is contained in:
@@ -25,12 +25,14 @@ _"when this becomes true"_. A few types are restricted to one role (`config` is
|
|||||||
trigger-only; the composites and `user` / `user_agent` are condition-only), as
|
trigger-only; the composites and `user` / `user_agent` are condition-only), as
|
||||||
noted at the top of each type below.
|
noted at the top of each type below.
|
||||||
|
|
||||||
For the card-state types (`camera`, `view`, `fullscreen`, `expand`, `call`,
|
For the card-state types (`camera`, `view`, `fullscreen`, `expand`,
|
||||||
`display_mode`, `media_loaded`, `microphone`, `interaction`, `triggered`) a
|
`display_mode`, `media_loaded`, `microphone`, `interaction`, `triggered`) a
|
||||||
trigger's value is **optional**: give it a value to fire only when the state
|
trigger's value is **optional**: give it a value to fire only when the state
|
||||||
changes _to_ that value, or **omit it to fire on any change**. (The stock `state`
|
changes _to_ that value, or **omit it to fire on any change**. (The stock `state`
|
||||||
trigger behaves the same way when `from`/`to` are omitted). As a condition the
|
trigger behaves the same way when `from`/`to` are omitted). As a condition the
|
||||||
value keeps its usual per-type meaning, as described below.
|
value keeps its usual per-type meaning, as described below. `call` is the
|
||||||
|
exception: its trigger matches a change by where it started and ended, using
|
||||||
|
`from`/`to` like the stock `state` trigger.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# A trigger initiates an automation; conditions are then checked.
|
# A trigger initiates an automation; conditions are then checked.
|
||||||
@@ -94,25 +96,56 @@ conditions:
|
|||||||
|
|
||||||
## `call`
|
## `call`
|
||||||
|
|
||||||
Matches whether a [two-way audio](../usage/2-way-audio.md) call is in progress.
|
Matches the phase of a [two-way audio](../usage/2-way-audio.md) call. A call is
|
||||||
As a **condition**, true while the call state matches; as a **trigger**, fires
|
in one of three phases:
|
||||||
when it becomes a match (e.g. `call: true` fires when a call starts).
|
|
||||||
|
| Phase | Meaning |
|
||||||
|
| ---------- | ------------------------------------------------------------- |
|
||||||
|
| `idle` | No call is in progress. |
|
||||||
|
| `ringing` | An inbound call is ringing and has not been answered. |
|
||||||
|
| `answered` | A call is in progress. An outbound call starts in this phase. |
|
||||||
|
|
||||||
|
As a **condition**, true while the call is in a matching phase. As a **trigger**,
|
||||||
|
fires when the phase _changes_, optionally limited to changes that start at
|
||||||
|
`from` and end at `to`. This is what separates answering a call from rejecting
|
||||||
|
one: both leave `ringing`, but they arrive at different phases.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# As a condition:
|
# As a condition:
|
||||||
conditions:
|
conditions:
|
||||||
- condition: call
|
- condition: call
|
||||||
call: true
|
call: [ringing, answered]
|
||||||
# As a trigger:
|
# As a trigger:
|
||||||
triggers:
|
triggers:
|
||||||
|
# An inbound call was answered.
|
||||||
- trigger: call
|
- trigger: call
|
||||||
call: true
|
from: ringing
|
||||||
|
to: answered
|
||||||
|
# A ringing call ended without being answered (rejected, timed out, or the
|
||||||
|
# user navigated away).
|
||||||
|
- trigger: call
|
||||||
|
from: ringing
|
||||||
|
to: idle
|
||||||
|
# An answered call was hung up.
|
||||||
|
- trigger: call
|
||||||
|
from: answered
|
||||||
|
to: idle
|
||||||
|
# A ringing call was either answered or rejected.
|
||||||
|
- trigger: call
|
||||||
|
from: ringing
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| ----------------------- | ---------------------------------------------------------------------------------------------- |
|
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `condition` / `trigger` | Must be `call`. |
|
| `condition` / `trigger` | Must be `call`. |
|
||||||
| `call` | If `true` or `false`, matches when a two-way audio call is or is not in progress respectively. |
|
| `call` | Condition only. A phase, or a list of phases any of which match. |
|
||||||
|
| `from` | Trigger only. Restricts firing to changes that start at this phase, or any phase in a list. If omitted, the phase changed from may be anything. |
|
||||||
|
| `to` | Trigger only. Restricts firing to changes that end at this phase, or any phase in a list. If omitted, the phase changed to may be anything. |
|
||||||
|
|
||||||
|
?> An outbound call is answered as soon as it connects, so it moves from `idle`
|
||||||
|
straight to `answered` without ever ringing. A trigger with `to: answered` and no
|
||||||
|
`from` will therefore also fire when the user starts an outbound call. Add
|
||||||
|
`from: ringing` to match only inbound calls that were answered.
|
||||||
|
|
||||||
## `camera`
|
## `camera`
|
||||||
|
|
||||||
@@ -721,7 +754,7 @@ conditions:
|
|||||||
- condition: view
|
- condition: view
|
||||||
views: [live]
|
views: [live]
|
||||||
- condition: call
|
- condition: call
|
||||||
call: true
|
call: [ringing, answered]
|
||||||
- condition: camera
|
- condition: camera
|
||||||
cameras:
|
cameras:
|
||||||
- camera.office
|
- camera.office
|
||||||
@@ -786,7 +819,8 @@ conditions:
|
|||||||
```yaml
|
```yaml
|
||||||
triggers:
|
triggers:
|
||||||
- trigger: call
|
- trigger: call
|
||||||
call: true
|
from: ringing
|
||||||
|
to: answered
|
||||||
- trigger: camera
|
- trigger: camera
|
||||||
cameras:
|
cameras:
|
||||||
- camera.office
|
- camera.office
|
||||||
|
|||||||
@@ -423,6 +423,85 @@ profiles:
|
|||||||
- doorbell
|
- doorbell
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Alerting the rest of the house
|
||||||
|
|
||||||
|
A wall tablet only helps whoever is standing in front of it. This example uses
|
||||||
|
the [`call` trigger](configuration/conditions-triggers.md?id=call) to drive an
|
||||||
|
`input_boolean` that a normal Home Assistant automation can watch, so the rest of
|
||||||
|
the house "rings" too -- and, crucially, **stops** ringing the moment somebody deals
|
||||||
|
with the caller.
|
||||||
|
|
||||||
|
The card knows things Home Assistant cannot see on its own: whether anyone
|
||||||
|
actually picked the call up, and whether it was answered or dismissed. Each
|
||||||
|
phase change gets its own automation.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: custom:advanced-camera-card
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.front_door
|
||||||
|
live_provider: go2rtc
|
||||||
|
go2rtc:
|
||||||
|
modes:
|
||||||
|
- webrtc
|
||||||
|
profiles:
|
||||||
|
- doorbell
|
||||||
|
automations:
|
||||||
|
# It started ringing: ring the rest of the house and light the porch so the
|
||||||
|
# visitor is actually visible on camera.
|
||||||
|
- triggers:
|
||||||
|
- trigger: call
|
||||||
|
to: ringing
|
||||||
|
actions:
|
||||||
|
- action: perform-action
|
||||||
|
perform_action: input_boolean.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: input_boolean.doorbell_ringing
|
||||||
|
- action: perform-action
|
||||||
|
perform_action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.porch
|
||||||
|
|
||||||
|
# Somebody answered on the tablet: silence every other device immediately.
|
||||||
|
# `from: ringing` keeps this from firing when *you* start an outbound call.
|
||||||
|
- triggers:
|
||||||
|
- trigger: call
|
||||||
|
from: ringing
|
||||||
|
to: answered
|
||||||
|
actions:
|
||||||
|
- action: perform-action
|
||||||
|
perform_action: input_boolean.turn_off
|
||||||
|
target:
|
||||||
|
entity_id: input_boolean.doorbell_ringing
|
||||||
|
|
||||||
|
# Nobody picked up, or somebody rejected it: stop ringing, and announce that
|
||||||
|
# a visitor was missed.
|
||||||
|
- triggers:
|
||||||
|
- trigger: call
|
||||||
|
from: ringing
|
||||||
|
to: idle
|
||||||
|
actions:
|
||||||
|
- action: perform-action
|
||||||
|
perform_action: input_boolean.turn_off
|
||||||
|
target:
|
||||||
|
entity_id: input_boolean.doorbell_ringing
|
||||||
|
- action: perform-action
|
||||||
|
perform_action: notify.mobile_app_phone
|
||||||
|
data:
|
||||||
|
message: Somebody was at the front door and nobody answered.
|
||||||
|
|
||||||
|
# The conversation ended. Only fires for calls that were actually answered,
|
||||||
|
# so an unanswered ring never turns the porch light off early.
|
||||||
|
- triggers:
|
||||||
|
- trigger: call
|
||||||
|
from: answered
|
||||||
|
to: idle
|
||||||
|
actions:
|
||||||
|
- action: perform-action
|
||||||
|
perform_action: light.turn_off
|
||||||
|
target:
|
||||||
|
entity_id: light.porch
|
||||||
|
```
|
||||||
|
|
||||||
## Events from other cameras
|
## Events from other cameras
|
||||||
|
|
||||||
`dependencies.cameras` allows events/recordings for other cameras to be shown
|
`dependencies.cameras` allows events/recordings for other cameras to be shown
|
||||||
|
|||||||
@@ -159,9 +159,11 @@ export class CallManager {
|
|||||||
modifiers: [new SubstreamViewModifier({ stream: callCameraID, camera: parentID })],
|
modifiers: [new SubstreamViewModifier({ stream: callCameraID, camera: parentID })],
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
this._api.getConditionStateManager().setState({ call: true });
|
this._api
|
||||||
|
.getConditionStateManager()
|
||||||
|
.setState({ call: answered ? 'answered' : 'ringing' });
|
||||||
|
|
||||||
// Re-read the session as the listeners triggered by `call: true` may have
|
// Re-read the session as the listeners triggered by the call phase may have
|
||||||
// already have changed the state.
|
// already have changed the state.
|
||||||
const call = this._call;
|
const call = this._call;
|
||||||
if (!call) {
|
if (!call) {
|
||||||
@@ -209,6 +211,7 @@ export class CallManager {
|
|||||||
// change. The `update()` below forces card.ts to re-render and re-read
|
// change. The `update()` below forces card.ts to re-render and re-read
|
||||||
// `getCall()`, propagating the new session to the carousel.
|
// `getCall()`, propagating the new session to the carousel.
|
||||||
this._call = { ...this._call, answered: true };
|
this._call = { ...this._call, answered: true };
|
||||||
|
this._api.getConditionStateManager().setState({ call: 'answered' });
|
||||||
this._api.getCardElementManager().update();
|
this._api.getCardElementManager().update();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -247,7 +250,7 @@ export class CallManager {
|
|||||||
this._unansweredTimer.stop();
|
this._unansweredTimer.stop();
|
||||||
if (this._call) {
|
if (this._call) {
|
||||||
this._call = null;
|
this._call = null;
|
||||||
this._api.getConditionStateManager().setState({ call: false });
|
this._api.getConditionStateManager().setState({ call: 'idle' });
|
||||||
}
|
}
|
||||||
this._api
|
this._api
|
||||||
.getConditionStateManager()
|
.getConditionStateManager()
|
||||||
@@ -305,7 +308,7 @@ export class CallManager {
|
|||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this._api.getConditionStateManager().setState({ call: false });
|
this._api.getConditionStateManager().setState({ call: 'idle' });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CallBase } from '../../../config/schema/condition-trigger/common/call';
|
import type { CallBase } from '../../../config/schema/condition-trigger/common/call';
|
||||||
|
import { arrayify } from '../../../utils/basic';
|
||||||
import type { ConditionsEvaluationResult, ConditionState } from '../types';
|
import type { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||||
import type { ConditionEvaluator } from './types';
|
import type { ConditionEvaluator } from './types';
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ export class CallConditionEvaluator implements ConditionEvaluator {
|
|||||||
|
|
||||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||||
return {
|
return {
|
||||||
result: this._condition.call === (newState?.call ?? false),
|
result: arrayify(this._condition.call).includes(newState?.call ?? 'idle'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,11 +87,12 @@ const triggerHasConditionValue = (trigger: Trigger): boolean =>
|
|||||||
Object.keys(trigger).some((key) => key !== 'trigger' && key !== 'enabled');
|
Object.keys(trigger).some((key) => key !== 'trigger' && key !== 'enabled');
|
||||||
|
|
||||||
// Build the condition evaluator a card trigger checks its changes against, or
|
// Build the condition evaluator a card trigger checks its changes against, or
|
||||||
// null if it has none -- it then fires on any change of its watched state. A
|
// null if it has none. A trigger has no condition when it carries no value (the
|
||||||
// trigger has no condition when it carries no value (the any-change form) or
|
// any-change form) or is trigger-only (`config`) -- both then fire on any change
|
||||||
// when it is trigger-only (`config`). Otherwise the trigger and its condition
|
// of the watched state -- or when it matches the transition itself rather than
|
||||||
// share a base schema, so an evaluator (typed on that base) is built directly
|
// its result (`call`), which does its own filtering. Otherwise the trigger and
|
||||||
// from the trigger -- no discriminator-swap.
|
// its condition share a base schema, so an evaluator (typed on that base) is
|
||||||
|
// built directly from the trigger -- no discriminator-swap.
|
||||||
export const createConditionEvaluatorForTrigger = (
|
export const createConditionEvaluatorForTrigger = (
|
||||||
trigger: Trigger,
|
trigger: Trigger,
|
||||||
): ConditionEvaluator | null => {
|
): ConditionEvaluator | null => {
|
||||||
@@ -114,8 +115,6 @@ export const createConditionEvaluatorForTrigger = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (trigger.trigger) {
|
switch (trigger.trigger) {
|
||||||
case 'call':
|
|
||||||
return new CallConditionEvaluator(trigger);
|
|
||||||
case 'camera':
|
case 'camera':
|
||||||
return new CameraConditionEvaluator(trigger);
|
return new CameraConditionEvaluator(trigger);
|
||||||
case 'display_mode':
|
case 'display_mode':
|
||||||
@@ -139,8 +138,9 @@ export const createConditionEvaluatorForTrigger = (
|
|||||||
case 'config':
|
case 'config':
|
||||||
return null;
|
return null;
|
||||||
default:
|
default:
|
||||||
// Stock triggers (`state`/`numeric_state`/`template`) evaluate themselves
|
// Only `call` reaches here. It matches the transition rather than its
|
||||||
// and never reuse a card condition through this path.
|
// result, so it builds its own `from`/`to` evaluators from the call
|
||||||
|
// condition.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { KeysState, MicrophoneState } from '../../card-controller/types';
|
import type { KeysState, MicrophoneState } from '../../card-controller/types';
|
||||||
import type { AdvancedCameraCardView } from '../../config/schema/common/const';
|
import type { AdvancedCameraCardView } from '../../config/schema/common/const';
|
||||||
import type { ViewDisplayMode } from '../../config/schema/common/display';
|
import type { ViewDisplayMode } from '../../config/schema/common/display';
|
||||||
|
import type { CallPhase } from '../../config/schema/condition-trigger/common/call';
|
||||||
import type { AdvancedCameraCardConfig } from '../../config/schema/types';
|
import type { AdvancedCameraCardConfig } from '../../config/schema/types';
|
||||||
import type { HomeAssistant } from '../../ha/types';
|
import type { HomeAssistant } from '../../ha/types';
|
||||||
import type { MediaLoadedInfo } from '../../types';
|
import type { MediaLoadedInfo } from '../../types';
|
||||||
@@ -16,7 +17,7 @@ import type { MediaLoadedInfo } from '../../types';
|
|||||||
// Counterexample: Rebuilding an equivalent function callback each write making
|
// Counterexample: Rebuilding an equivalent function callback each write making
|
||||||
// the field look changed when nothing observable did.
|
// the field look changed when nothing observable did.
|
||||||
export interface ConditionState {
|
export interface ConditionState {
|
||||||
call?: boolean;
|
call?: CallPhase;
|
||||||
camera?: string;
|
camera?: string;
|
||||||
// The engaged substream for the selected camera (absent when the camera's own
|
// The engaged substream for the selected camera (absent when the camera's own
|
||||||
// stream is used).
|
// stream is used).
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
|
import { CallConditionEvaluator } from '../../conditions/conditions/call';
|
||||||
import type { ConditionState } from '../../conditions/types';
|
import type { ConditionState } from '../../conditions/types';
|
||||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
import {
|
||||||
|
ConditionStateTriggerBase,
|
||||||
|
type TransitionEvaluators,
|
||||||
|
} from './condition-state-base';
|
||||||
import type { TriggerOfType } from './types';
|
import type { TriggerOfType } from './types';
|
||||||
|
|
||||||
export class CallTrigger extends ConditionStateTriggerBase<TriggerOfType<'call'>> {
|
export class CallTrigger extends ConditionStateTriggerBase<TriggerOfType<'call'>> {
|
||||||
protected _getValue(state: ConditionState): unknown {
|
protected _getValue(state: ConditionState): unknown {
|
||||||
return state.call ?? false;
|
return state.call ?? 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
// `from` and `to` are matched with the call condition's own evaluator, so the
|
||||||
|
// trigger and the condition share one definition of what a phase means.
|
||||||
|
protected _createTransitionEvaluators(): TransitionEvaluators {
|
||||||
|
const from = this._trigger.from;
|
||||||
|
const to = this._trigger.to;
|
||||||
|
return {
|
||||||
|
...(from !== undefined && { from: new CallConditionEvaluator({ call: from }) }),
|
||||||
|
...(to !== undefined && { to: new CallConditionEvaluator({ call: to }) }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,25 @@ import type {
|
|||||||
TriggerEvaluatorContext,
|
TriggerEvaluatorContext,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
// Evaluators a state change must satisfy: `from` is checked against the state
|
||||||
|
// before the change, `to` against the state after it. Either may be omitted, in
|
||||||
|
// which case that check is skipped.
|
||||||
|
export interface TransitionEvaluators {
|
||||||
|
from?: ConditionEvaluator;
|
||||||
|
to?: ConditionEvaluator;
|
||||||
|
}
|
||||||
|
|
||||||
// A trigger driven by `ConditionState` changes: subscribe to the state manager,
|
// A trigger driven by `ConditionState` changes: subscribe to the state manager,
|
||||||
// fire when the watched value (`_getValue`) changes and the new state passes
|
// fire when the watched value (`_getValue`) changes and the new state passes
|
||||||
// the trigger's condition, and emit the `acc` payload. The condition is the
|
// the trigger's condition, and emit the `acc` payload. The condition is the
|
||||||
// matching condition reused as a point-in-time predicate -- so a trigger and
|
// matching condition reused as a point-in-time predicate -- so a trigger and
|
||||||
// its condition share one definition of meaning. A trigger with no value (any
|
// its condition share one definition of meaning. A trigger with no value (any
|
||||||
// change), or no matching condition (`config`), has no condition to pass.
|
// change), or no matching condition (`config`), has no condition to pass.
|
||||||
|
//
|
||||||
|
// A trigger type may additionally match the change itself rather than only its
|
||||||
|
// result, by supplying `_createTransitionEvaluators()`. The same condition
|
||||||
|
// evaluator is then read twice: against the state before the change and the
|
||||||
|
// state after it.
|
||||||
export abstract class ConditionStateTriggerBase<T extends Trigger>
|
export abstract class ConditionStateTriggerBase<T extends Trigger>
|
||||||
implements TriggerEvaluator
|
implements TriggerEvaluator
|
||||||
{
|
{
|
||||||
@@ -24,18 +37,23 @@ export abstract class ConditionStateTriggerBase<T extends Trigger>
|
|||||||
protected _context: TriggerEvaluatorContext;
|
protected _context: TriggerEvaluatorContext;
|
||||||
|
|
||||||
private _callback: TriggerCallback | null = null;
|
private _callback: TriggerCallback | null = null;
|
||||||
private _condition: ConditionEvaluator | null;
|
private _condition: ConditionEvaluator | null = null;
|
||||||
|
private _transition: TransitionEvaluators | null = null;
|
||||||
|
|
||||||
constructor(trigger: T, context: TriggerEvaluatorContext) {
|
constructor(trigger: T, context: TriggerEvaluatorContext) {
|
||||||
this._trigger = trigger;
|
this._trigger = trigger;
|
||||||
this._context = context;
|
this._context = context;
|
||||||
|
|
||||||
// The condition the trigger checks each change against.
|
|
||||||
this._condition = createConditionEvaluatorForTrigger(trigger);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public subscribe(callback: TriggerCallback): void {
|
public subscribe(callback: TriggerCallback): void {
|
||||||
this._callback = callback;
|
this._callback = callback;
|
||||||
|
|
||||||
|
// Both are built here rather than in the constructor, which runs before a
|
||||||
|
// subclass has initialized its own fields -- and so before an overridden
|
||||||
|
// `_createTransitionEvaluators()` could read them.
|
||||||
|
this._condition = createConditionEvaluatorForTrigger(this._trigger);
|
||||||
|
this._transition = this._createTransitionEvaluators();
|
||||||
|
|
||||||
this._context.stateManager.addListener(this._handler);
|
this._context.stateManager.addListener(this._handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,9 +69,23 @@ export abstract class ConditionStateTriggerBase<T extends Trigger>
|
|||||||
if (this._condition && !this._condition.evaluate(change.new).result) {
|
if (this._condition && !this._condition.evaluate(change.new).result) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const transition = this._transition;
|
||||||
|
if (transition?.from && !transition.from.evaluate(change.old).result) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (transition?.to && !transition.to.evaluate(change.new).result) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._callback?.(buildCardTriggerData(this._trigger.trigger, change));
|
this._callback?.(buildCardTriggerData(this._trigger.trigger, change));
|
||||||
};
|
};
|
||||||
|
|
||||||
// The slice of state this trigger watches; it fires only when this changes.
|
// The slice of state this trigger watches; it fires only when this changes.
|
||||||
protected abstract _getValue(state: ConditionState): unknown;
|
protected abstract _getValue(state: ConditionState): unknown;
|
||||||
|
|
||||||
|
// What the states before and after a change must satisfy, for a trigger type
|
||||||
|
// that matches transitions. `null` (the default) matches on the new state
|
||||||
|
// alone.
|
||||||
|
protected _createTransitionEvaluators(): TransitionEvaluators | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -591,6 +591,17 @@ const rewriteConditionAsTrigger = (condition: unknown): unknown => {
|
|||||||
}
|
}
|
||||||
const kind = condition['condition'];
|
const kind = condition['condition'];
|
||||||
|
|
||||||
|
// Only the renamed fields are consumed; anything else the condition carries
|
||||||
|
// (`enabled`, and the fields it already shares with its trigger) is preserved,
|
||||||
|
// so promoting never silently discards user configuration.
|
||||||
|
const withoutKeys = (...keys: string[]): Record<string, unknown> => {
|
||||||
|
const rest = { ...condition };
|
||||||
|
for (const key of keys) {
|
||||||
|
delete rest[key];
|
||||||
|
}
|
||||||
|
return rest;
|
||||||
|
};
|
||||||
|
|
||||||
// A `state` condition maps onto the HA state trigger (`state` -> `to`,
|
// A `state` condition maps onto the HA state trigger (`state` -> `to`,
|
||||||
// `state_not` -> `not_to`). A discriminator-less condition is the bare
|
// `state_not` -> `not_to`). A discriminator-less condition is the bare
|
||||||
// picture-element state form -- the only condition that may omit `condition`.
|
// picture-element state form -- the only condition that may omit `condition`.
|
||||||
@@ -598,19 +609,28 @@ const rewriteConditionAsTrigger = (condition: unknown): unknown => {
|
|||||||
const entityId = condition['entity_id'] ?? condition['entity'];
|
const entityId = condition['entity_id'] ?? condition['entity'];
|
||||||
return {
|
return {
|
||||||
trigger: 'state',
|
trigger: 'state',
|
||||||
|
...withoutKeys('condition', 'entity', 'entity_id', 'state', 'state_not'),
|
||||||
...(entityId !== undefined && { entity_id: entityId }),
|
...(entityId !== undefined && { entity_id: entityId }),
|
||||||
...(condition['state'] !== undefined && { to: condition['state'] }),
|
...(condition['state'] !== undefined && { to: condition['state'] }),
|
||||||
...(condition['state_not'] !== undefined && { not_to: condition['state_not'] }),
|
...(condition['state_not'] !== undefined && { not_to: condition['state_not'] }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A `call` condition describes a phase; as a trigger it is the arrival at
|
||||||
|
// that phase.
|
||||||
|
if (kind === 'call') {
|
||||||
|
return {
|
||||||
|
trigger: 'call',
|
||||||
|
...withoutKeys('condition', 'call'),
|
||||||
|
...(condition['call'] !== undefined && { to: condition['call'] }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Every other condition -- the stock `numeric_state`/`template` and all the
|
// Every other condition -- the stock `numeric_state`/`template` and all the
|
||||||
// card-specific kinds -- shares its field names with the matching trigger
|
// card-specific kinds -- shares its field names with the matching trigger
|
||||||
// (only `state` involves internal field renames), so promoting is just a
|
// (only `state` and `call` involve internal field renames), so promoting is
|
||||||
// discriminator swap.
|
// just a discriminator swap.
|
||||||
const rest = { ...condition };
|
return { trigger: kind, ...withoutKeys('condition') };
|
||||||
delete rest['condition'];
|
|
||||||
return { trigger: kind, ...rest };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1427,19 +1447,33 @@ const microphoneConnectedToCallTransform = (data: unknown): boolean => {
|
|||||||
}
|
}
|
||||||
const muted = data['muted'];
|
const muted = data['muted'];
|
||||||
|
|
||||||
|
// Survives the rebuild below: a condition the user had switched off must not
|
||||||
|
// come back switched on.
|
||||||
|
const enabled = data['enabled'];
|
||||||
|
|
||||||
|
// A connected microphone meant a call was underway, which is either of the
|
||||||
|
// two active phases; a disconnected one meant no call at all.
|
||||||
|
// This needs to be revisited: https://github.com/dermotduffy/advanced-camera-card/issues/2590
|
||||||
|
const call = connected ? ['ringing', 'answered'] : 'idle';
|
||||||
|
|
||||||
for (const key of Object.keys(data)) {
|
for (const key of Object.keys(data)) {
|
||||||
delete data[key];
|
delete data[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof muted === 'boolean') {
|
if (typeof muted === 'boolean') {
|
||||||
|
// `enabled` goes on the composite, disabling both halves together exactly
|
||||||
|
// as it disabled the single condition it replaces.
|
||||||
data['condition'] = 'and';
|
data['condition'] = 'and';
|
||||||
data['conditions'] = [
|
data['conditions'] = [
|
||||||
{ condition: 'call', call: connected },
|
{ condition: 'call', call: call },
|
||||||
{ condition: 'microphone', muted: muted },
|
{ condition: 'microphone', muted: muted },
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
data['condition'] = 'call';
|
data['condition'] = 'call';
|
||||||
data['call'] = connected;
|
data['call'] = call;
|
||||||
|
}
|
||||||
|
if (enabled !== undefined) {
|
||||||
|
data['enabled'] = enabled;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// The lifecycle of a two-way audio call. An outbound call has no `ringing`
|
||||||
|
// phase: it is answered by construction.
|
||||||
|
const callPhaseSchema = z.enum(['idle', 'ringing', 'answered']);
|
||||||
|
export type CallPhase = z.infer<typeof callPhaseSchema>;
|
||||||
|
|
||||||
|
// Matches a single phase, or any one of a list of them.
|
||||||
|
export const callPhaseMatchSchema = callPhaseSchema.or(callPhaseSchema.array());
|
||||||
|
|
||||||
export const callBaseSchema = z.object({
|
export const callBaseSchema = z.object({
|
||||||
call: z.boolean().optional(),
|
call: callPhaseMatchSchema.optional(),
|
||||||
});
|
});
|
||||||
export type CallBase = z.infer<typeof callBaseSchema>;
|
export type CallBase = z.infer<typeof callBaseSchema>;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { callBaseSchema } from '../../common/call';
|
import { callBaseSchema, callPhaseMatchSchema } from '../../common/call';
|
||||||
import { conditionBaseSchema } from '../base';
|
import { conditionBaseSchema } from '../base';
|
||||||
|
|
||||||
export const callConditionSchema = callBaseSchema
|
export const callConditionSchema = callBaseSchema
|
||||||
.extend(conditionBaseSchema.shape)
|
.extend(conditionBaseSchema.shape)
|
||||||
.extend({
|
.extend({
|
||||||
condition: z.literal('call'),
|
condition: z.literal('call'),
|
||||||
call: z.boolean(),
|
call: callPhaseMatchSchema,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { callBaseSchema } from '../../common/call';
|
import { callPhaseMatchSchema } from '../../common/call';
|
||||||
import { triggerBaseSchema } from '../base';
|
import { triggerBaseSchema } from '../base';
|
||||||
|
|
||||||
export const callTriggerSchema = callBaseSchema
|
// Unlike the call condition (which matches a specific phase), the trigger
|
||||||
.extend(triggerBaseSchema.shape)
|
// matches a phase transition: `from` is checked against the phase before the
|
||||||
.extend({ trigger: z.literal('call') });
|
// change and `to` against the phase after it. An omitted `from` or `to` is not
|
||||||
|
// checked, so specifying neither matches any phase change.
|
||||||
|
export const callTriggerSchema = triggerBaseSchema.extend({
|
||||||
|
trigger: z.literal('call'),
|
||||||
|
from: callPhaseMatchSchema.optional(),
|
||||||
|
to: callPhaseMatchSchema.optional(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import type { PartialDeep } from 'type-fest';
|
import type { PartialDeep } from 'type-fest';
|
||||||
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { assert, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
import type { CameraManagerStore } from '../../../src/camera-manager/store';
|
import type { CameraManagerStore } from '../../../src/camera-manager/store';
|
||||||
@@ -9,10 +9,14 @@ import { CallManager } from '../../../src/card-controller/call/manager';
|
|||||||
import { Ringtone } from '../../../src/card-controller/call/ringtone';
|
import { Ringtone } from '../../../src/card-controller/call/ringtone';
|
||||||
import type { CardController } from '../../../src/card-controller/controller';
|
import type { CardController } from '../../../src/card-controller/controller';
|
||||||
import { SubstreamViewModifier } from '../../../src/card-controller/view/modifiers/substream';
|
import { SubstreamViewModifier } from '../../../src/card-controller/view/modifiers/substream';
|
||||||
|
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
|
||||||
import type { ConditionStateChange } from '../../../src/condition-trigger/conditions/types';
|
import type { ConditionStateChange } from '../../../src/condition-trigger/conditions/types';
|
||||||
|
import { CallTrigger } from '../../../src/condition-trigger/triggers/triggers/call';
|
||||||
|
import type { TriggerOfType } from '../../../src/condition-trigger/triggers/triggers/types';
|
||||||
import type { RingtoneConfig } from '../../../src/config/schema/live';
|
import type { RingtoneConfig } from '../../../src/config/schema/live';
|
||||||
import type { AdvancedCameraCardConfig } from '../../../src/config/schema/types';
|
import type { AdvancedCameraCardConfig } from '../../../src/config/schema/types';
|
||||||
import { View } from '../../../src/view/view';
|
import { View } from '../../../src/view/view';
|
||||||
|
import { createTriggerEvaluatorContext } from '../../condition-trigger/triggers/triggers/test-utils';
|
||||||
import {
|
import {
|
||||||
createCameraConfig,
|
createCameraConfig,
|
||||||
createCameraManager,
|
createCameraManager,
|
||||||
@@ -145,7 +149,6 @@ describe('start', () => {
|
|||||||
modifiers: [expect.any(SubstreamViewModifier)],
|
modifiers: [expect.any(SubstreamViewModifier)],
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should navigate to the live view when started from elsewhere', async () => {
|
it('should navigate to the live view when started from elsewhere', async () => {
|
||||||
@@ -367,8 +370,6 @@ describe('start', () => {
|
|||||||
expect(call?.cameraID).toBe('camera.garage');
|
expect(call?.cameraID).toBe('camera.garage');
|
||||||
expect(call?.previousView?.view).toBe('live');
|
expect(call?.previousView?.view).toBe('live');
|
||||||
expect(call?.previousView?.camera).toBe('camera.office');
|
expect(call?.previousView?.camera).toBe('camera.office');
|
||||||
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false });
|
|
||||||
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should restart on the same camera with a different stream', async () => {
|
it('should restart on the same camera with a different stream', async () => {
|
||||||
@@ -656,7 +657,6 @@ describe('end', () => {
|
|||||||
modifiers: [expect.any(SubstreamViewModifier)],
|
modifiers: [expect.any(SubstreamViewModifier)],
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should restore the pre-call substream when ending', async () => {
|
it('should restore the pre-call substream when ending', async () => {
|
||||||
@@ -1085,7 +1085,6 @@ describe('initialize / uninitialize', () => {
|
|||||||
manager.uninitialize();
|
manager.uninitialize();
|
||||||
|
|
||||||
expect(manager.isActive()).toBe(false);
|
expect(manager.isActive()).toBe(false);
|
||||||
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore further condition state changes after uninitialize', async () => {
|
it('should ignore further condition state changes after uninitialize', async () => {
|
||||||
@@ -1564,7 +1563,7 @@ describe('unanswered timeout', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// `start()` calls `setState({ call: true })` to broadcast the new call status;
|
// `start()` calls `setState()` to broadcast the new call phase;
|
||||||
// a listener that responds by navigating away will fire the manager's own
|
// a listener that responds by navigating away will fire the manager's own
|
||||||
// condition listener and end the call before `start()` returns. Verify the
|
// condition listener and end the call before `start()` returns. Verify the
|
||||||
// post-setState re-read of the session prevents follow-up work (ringtone /
|
// post-setState re-read of the session prevents follow-up work (ringtone /
|
||||||
@@ -1591,10 +1590,10 @@ describe('session end during setState', () => {
|
|||||||
const listener = getConditionStateListener(api);
|
const listener = getConditionStateListener(api);
|
||||||
|
|
||||||
vi.mocked(api.getConditionStateManager().setState).mockImplementation((state) => {
|
vi.mocked(api.getConditionStateManager().setState).mockImplementation((state) => {
|
||||||
// Simulate a downstream listener that responds to `call: true` by
|
// Simulate a downstream listener that responds to the inbound ring by
|
||||||
// navigating away. The manager's own listener then ends the call,
|
// navigating away. The manager's own listener then ends the call,
|
||||||
// nulling the session before `start()` finishes.
|
// nulling the session before `start()` finishes.
|
||||||
if (state.call === true) {
|
if (state.call === 'ringing') {
|
||||||
listener({
|
listener({
|
||||||
old: { camera: 'camera.office', view: 'live' },
|
old: { camera: 'camera.office', view: 'live' },
|
||||||
change: { view: 'clips' },
|
change: { view: 'clips' },
|
||||||
@@ -1683,3 +1682,187 @@ describe('uninitialize during in-flight start', () => {
|
|||||||
expect(api.getNotificationManager().setNotification).not.toBeCalled();
|
expect(api.getNotificationManager().setNotification).not.toBeCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The phase the manager publishes is what automations actually react to, so
|
||||||
|
// these drive a real ConditionStateManager and a real CallTrigger and assert
|
||||||
|
// the transitions an automation would fire on, rather than that `setState` was
|
||||||
|
// called.
|
||||||
|
describe('published phase transitions in condition state', () => {
|
||||||
|
const createAPIWithRealStateManager = (options?: {
|
||||||
|
config?: PartialDeep<AdvancedCameraCardConfig>;
|
||||||
|
store?: CameraManagerStore;
|
||||||
|
}): { api: CardController; stateManager: ConditionStateManager } => {
|
||||||
|
const stateManager = new ConditionStateManager();
|
||||||
|
const api = createAPI({
|
||||||
|
view: createView({ camera: 'camera.office' }),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||||
|
return { api, stateManager };
|
||||||
|
};
|
||||||
|
|
||||||
|
const watch = (
|
||||||
|
stateManager: ConditionStateManager,
|
||||||
|
trigger: TriggerOfType<'call'>,
|
||||||
|
): Mock => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
new CallTrigger(trigger, createTriggerEvaluatorContext({ stateManager })).subscribe(
|
||||||
|
callback,
|
||||||
|
);
|
||||||
|
return callback;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should fire a ringing trigger when an inbound call starts', async () => {
|
||||||
|
const { api, stateManager } = createAPIWithRealStateManager();
|
||||||
|
const manager = new CallManager(api);
|
||||||
|
manager.initialize();
|
||||||
|
|
||||||
|
const ringing = watch(stateManager, { trigger: 'call', to: 'ringing' });
|
||||||
|
const answered = watch(stateManager, { trigger: 'call', to: 'answered' });
|
||||||
|
|
||||||
|
expect(await manager.start({ inbound: true })).toBe(true);
|
||||||
|
|
||||||
|
expect(ringing).toHaveBeenCalledTimes(1);
|
||||||
|
expect(answered).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fire an answered trigger when an outbound call starts', async () => {
|
||||||
|
const { api, stateManager } = createAPIWithRealStateManager();
|
||||||
|
const manager = new CallManager(api);
|
||||||
|
manager.initialize();
|
||||||
|
|
||||||
|
const ringing = watch(stateManager, { trigger: 'call', to: 'ringing' });
|
||||||
|
const answered = watch(stateManager, { trigger: 'call', to: 'answered' });
|
||||||
|
|
||||||
|
// Outbound calls are answered by construction, so they never ring.
|
||||||
|
expect(await manager.start()).toBe(true);
|
||||||
|
|
||||||
|
expect(answered).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ringing).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fire an end trigger when an outbound call ends', async () => {
|
||||||
|
const { api, stateManager } = createAPIWithRealStateManager();
|
||||||
|
const manager = new CallManager(api);
|
||||||
|
manager.initialize();
|
||||||
|
|
||||||
|
const ended = watch(stateManager, { trigger: 'call', to: 'idle' });
|
||||||
|
|
||||||
|
expect(await manager.start()).toBe(true);
|
||||||
|
expect(manager.end()).toBe(true);
|
||||||
|
|
||||||
|
expect(ended).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fire an answer trigger only for an inbound call that was answered', async () => {
|
||||||
|
const { api, stateManager } = createAPIWithRealStateManager();
|
||||||
|
const manager = new CallManager(api);
|
||||||
|
manager.initialize();
|
||||||
|
|
||||||
|
const answered = watch(stateManager, {
|
||||||
|
trigger: 'call',
|
||||||
|
from: 'ringing',
|
||||||
|
to: 'answered',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await manager.start({ inbound: true })).toBe(true);
|
||||||
|
expect(answered).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(manager.answer()).toBe(true);
|
||||||
|
|
||||||
|
expect(answered).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fire a reject trigger when an unanswered call times out', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { api, stateManager } = createAPIWithRealStateManager({
|
||||||
|
config: { live: { controls: { call: { unanswered_timeout_seconds: 60 } } } },
|
||||||
|
});
|
||||||
|
const manager = new CallManager(api);
|
||||||
|
manager.initialize();
|
||||||
|
|
||||||
|
const rejected = watch(stateManager, {
|
||||||
|
trigger: 'call',
|
||||||
|
from: 'ringing',
|
||||||
|
to: 'idle',
|
||||||
|
});
|
||||||
|
const hungUp = watch(stateManager, {
|
||||||
|
trigger: 'call',
|
||||||
|
from: 'answered',
|
||||||
|
to: 'idle',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await manager.start({ inbound: true })).toBe(true);
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
|
||||||
|
expect(rejected).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hungUp).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fire a hangup trigger, not a reject, when an answered call ends', async () => {
|
||||||
|
const { api, stateManager } = createAPIWithRealStateManager();
|
||||||
|
const manager = new CallManager(api);
|
||||||
|
manager.initialize();
|
||||||
|
const rejected = watch(stateManager, {
|
||||||
|
trigger: 'call',
|
||||||
|
from: 'ringing',
|
||||||
|
to: 'idle',
|
||||||
|
});
|
||||||
|
const hungUp = watch(stateManager, {
|
||||||
|
trigger: 'call',
|
||||||
|
from: 'answered',
|
||||||
|
to: 'idle',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await manager.start({ inbound: true })).toBe(true);
|
||||||
|
expect(manager.answer()).toBe(true);
|
||||||
|
expect(manager.end()).toBe(true);
|
||||||
|
|
||||||
|
expect(hungUp).toHaveBeenCalledTimes(1);
|
||||||
|
expect(rejected).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fire a reject trigger when a ringing call is superseded', async () => {
|
||||||
|
const { api, stateManager } = createAPIWithRealStateManager({
|
||||||
|
store: createStore([
|
||||||
|
{
|
||||||
|
cameraID: 'camera.office',
|
||||||
|
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cameraID: 'camera.garage',
|
||||||
|
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const manager = new CallManager(api);
|
||||||
|
manager.initialize();
|
||||||
|
|
||||||
|
const rejected = watch(stateManager, {
|
||||||
|
trigger: 'call',
|
||||||
|
from: 'ringing',
|
||||||
|
to: 'idle',
|
||||||
|
});
|
||||||
|
const ringing = watch(stateManager, { trigger: 'call', to: 'ringing' });
|
||||||
|
|
||||||
|
expect(await manager.start({ inbound: true })).toBe(true);
|
||||||
|
expect(await manager.start({ inbound: true, cameraID: 'camera.garage' })).toBe(true);
|
||||||
|
|
||||||
|
// The superseded ring is observably ended before the replacement rings, so
|
||||||
|
// an automation sees idle in between rather than one continuous call.
|
||||||
|
expect(rejected).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ringing).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should publish idle when uninitialized during a call', async () => {
|
||||||
|
const { api, stateManager } = createAPIWithRealStateManager();
|
||||||
|
const manager = new CallManager(api);
|
||||||
|
manager.initialize();
|
||||||
|
const ended = watch(stateManager, { trigger: 'call', to: 'idle' });
|
||||||
|
|
||||||
|
expect(await manager.start({ inbound: true })).toBe(true);
|
||||||
|
manager.uninitialize();
|
||||||
|
|
||||||
|
expect(ended).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,27 +10,53 @@ describe('call condition', () => {
|
|||||||
expect(() => callConditionSchema.parse({ condition: 'call' })).toThrow();
|
expect(() => callConditionSchema.parse({ condition: 'call' })).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match when call is true', () => {
|
it('should reject a phase that does not exist', () => {
|
||||||
const evaluator = createConditionEvaluator(
|
expect(() =>
|
||||||
{ condition: 'call' as const, call: true },
|
callConditionSchema.parse({ condition: 'call', call: 'hungup' }),
|
||||||
createEvaluatorContext(),
|
).toThrow();
|
||||||
);
|
|
||||||
|
|
||||||
expect(evaluator.evaluate({}).result).toBeFalsy();
|
|
||||||
expect(evaluator.evaluate({ call: true }).result).toBeTruthy();
|
|
||||||
expect(evaluator.evaluate({ call: false }).result).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match when call is false', () => {
|
it('should match the ringing phase', () => {
|
||||||
const evaluator = createConditionEvaluator(
|
const evaluator = createConditionEvaluator(
|
||||||
{ condition: 'call' as const, call: false },
|
{ condition: 'call' as const, call: 'ringing' as const },
|
||||||
|
createEvaluatorContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(evaluator.evaluate({ call: 'ringing' }).result).toBeTruthy();
|
||||||
|
expect(evaluator.evaluate({ call: 'answered' }).result).toBeFalsy();
|
||||||
|
expect(evaluator.evaluate({ call: 'idle' }).result).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match the answered phase', () => {
|
||||||
|
const evaluator = createConditionEvaluator(
|
||||||
|
{ condition: 'call' as const, call: 'answered' as const },
|
||||||
|
createEvaluatorContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(evaluator.evaluate({ call: 'answered' }).result).toBeTruthy();
|
||||||
|
expect(evaluator.evaluate({ call: 'ringing' }).result).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat an absent call state as idle', () => {
|
||||||
|
const evaluator = createConditionEvaluator(
|
||||||
|
{ condition: 'call' as const, call: 'idle' as const },
|
||||||
createEvaluatorContext(),
|
createEvaluatorContext(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// With no state.call published, the bare condition matches `false`,
|
|
||||||
// so `call: false` is satisfied.
|
|
||||||
expect(evaluator.evaluate({}).result).toBeTruthy();
|
expect(evaluator.evaluate({}).result).toBeTruthy();
|
||||||
expect(evaluator.evaluate({ call: true }).result).toBeFalsy();
|
expect(evaluator.evaluate({ call: 'idle' }).result).toBeTruthy();
|
||||||
expect(evaluator.evaluate({ call: false }).result).toBeTruthy();
|
expect(evaluator.evaluate({ call: 'ringing' }).result).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match any phase in a list', () => {
|
||||||
|
const evaluator = createConditionEvaluator(
|
||||||
|
{ condition: 'call' as const, call: ['ringing', 'answered'] as const },
|
||||||
|
createEvaluatorContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(evaluator.evaluate({ call: 'ringing' }).result).toBeTruthy();
|
||||||
|
expect(evaluator.evaluate({ call: 'answered' }).result).toBeTruthy();
|
||||||
|
expect(evaluator.evaluate({ call: 'idle' }).result).toBeFalsy();
|
||||||
|
expect(evaluator.evaluate({}).result).toBeFalsy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { CallConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/call';
|
|
||||||
import { CameraConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/camera';
|
import { CameraConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/camera';
|
||||||
import { DisplayModeConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/display-mode';
|
import { DisplayModeConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/display-mode';
|
||||||
import { ExpandConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/expand';
|
import { ExpandConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/expand';
|
||||||
@@ -19,7 +18,6 @@ type ConditionEvaluatorConstructor = new (...args: never[]) => ConditionEvaluato
|
|||||||
|
|
||||||
describe('createConditionEvaluatorForTrigger', () => {
|
describe('createConditionEvaluatorForTrigger', () => {
|
||||||
it.each<[Trigger, ConditionEvaluatorConstructor]>([
|
it.each<[Trigger, ConditionEvaluatorConstructor]>([
|
||||||
[{ trigger: 'call', call: true }, CallConditionEvaluator],
|
|
||||||
[{ trigger: 'camera', cameras: ['front'] }, CameraConditionEvaluator],
|
[{ trigger: 'camera', cameras: ['front'] }, CameraConditionEvaluator],
|
||||||
[{ trigger: 'display_mode', display_mode: 'single' }, DisplayModeConditionEvaluator],
|
[{ trigger: 'display_mode', display_mode: 'single' }, DisplayModeConditionEvaluator],
|
||||||
[{ trigger: 'expand', expand: true }, ExpandConditionEvaluator],
|
[{ trigger: 'expand', expand: true }, ExpandConditionEvaluator],
|
||||||
@@ -40,6 +38,7 @@ describe('createConditionEvaluatorForTrigger', () => {
|
|||||||
it.each<[string, Trigger]>([
|
it.each<[string, Trigger]>([
|
||||||
['a valueless trigger fires on any change', { trigger: 'fullscreen' }],
|
['a valueless trigger fires on any change', { trigger: 'fullscreen' }],
|
||||||
['config has no matching condition', { trigger: 'config', paths: ['menu.style'] }],
|
['config has no matching condition', { trigger: 'config', paths: ['menu.style'] }],
|
||||||
|
['call matches the change itself', { trigger: 'call', from: 'ringing' }],
|
||||||
[
|
[
|
||||||
'stock triggers evaluate themselves',
|
'stock triggers evaluate themselves',
|
||||||
{ trigger: 'state', entity_id: 'binary_sensor.x' },
|
{ trigger: 'state', entity_id: 'binary_sensor.x' },
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe('createTriggerEvaluator', () => {
|
|||||||
[{ trigger: 'state', entity_id: 'binary_sensor.x' }, StateTrigger],
|
[{ trigger: 'state', entity_id: 'binary_sensor.x' }, StateTrigger],
|
||||||
[{ trigger: 'numeric_state', entity_id: 'sensor.x', above: 5 }, NumericStateTrigger],
|
[{ trigger: 'numeric_state', entity_id: 'sensor.x', above: 5 }, NumericStateTrigger],
|
||||||
[{ trigger: 'template', value_template: '{{ true }}' }, TemplateTrigger],
|
[{ trigger: 'template', value_template: '{{ true }}' }, TemplateTrigger],
|
||||||
[{ trigger: 'call', call: true }, CallTrigger],
|
[{ trigger: 'call', to: 'answered' }, CallTrigger],
|
||||||
[{ trigger: 'camera', cameras: ['front'] }, CameraTrigger],
|
[{ trigger: 'camera', cameras: ['front'] }, CameraTrigger],
|
||||||
[{ trigger: 'config' }, ConfigTrigger],
|
[{ trigger: 'config' }, ConfigTrigger],
|
||||||
[{ trigger: 'display_mode', display_mode: 'single' }, DisplayModeTrigger],
|
[{ trigger: 'display_mode', display_mode: 'single' }, DisplayModeTrigger],
|
||||||
|
|||||||
@@ -18,31 +18,152 @@ describe('CallTrigger', () => {
|
|||||||
return { stateManager, callback };
|
return { stateManager, callback };
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should treat an absent call state as not-in-call', () => {
|
it('should treat an absent call state as idle', () => {
|
||||||
const { stateManager, callback } = create({ trigger: 'call' });
|
const { stateManager, callback } = create({ trigger: 'call' });
|
||||||
|
|
||||||
// Absent (undefined) is equivalent to false, so this is not a change.
|
// Absent (undefined) is equivalent to idle, so this is not a change.
|
||||||
stateManager.setState({ call: false });
|
stateManager.setState({ call: 'idle' });
|
||||||
expect(callback).not.toHaveBeenCalled();
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
stateManager.setState({ call: true });
|
stateManager.setState({ call: 'ringing' });
|
||||||
expect(callback).toHaveBeenCalledTimes(1);
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should trigger only on changes to the given value', () => {
|
it('should trigger on any phase change without from or to', () => {
|
||||||
const { stateManager, callback } = create({ trigger: 'call', call: true });
|
const { stateManager, callback } = create({ trigger: 'call' });
|
||||||
stateManager.setState({ call: true });
|
|
||||||
stateManager.setState({ call: false });
|
stateManager.setState({ call: 'ringing' });
|
||||||
|
stateManager.setState({ call: 'answered' });
|
||||||
|
stateManager.setState({ call: 'idle' });
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not trigger when the phase is unchanged', () => {
|
||||||
|
const { stateManager, callback } = create({ trigger: 'call' });
|
||||||
|
|
||||||
|
stateManager.setState({ call: 'ringing' });
|
||||||
|
stateManager.setState({ call: 'ringing', camera: 'camera.office' });
|
||||||
|
|
||||||
expect(callback).toHaveBeenCalledTimes(1);
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should trigger on the falling edge to a false value', () => {
|
it('should trigger when an inbound call is answered', () => {
|
||||||
const { stateManager, callback } = create({ trigger: 'call', call: false });
|
const { stateManager, callback } = create({
|
||||||
|
trigger: 'call',
|
||||||
|
from: 'ringing',
|
||||||
|
to: 'answered',
|
||||||
|
});
|
||||||
|
|
||||||
stateManager.setState({ call: true });
|
stateManager.setState({ call: 'ringing' });
|
||||||
expect(callback).not.toHaveBeenCalled();
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
stateManager.setState({ call: false });
|
stateManager.setState({ call: 'answered' });
|
||||||
expect(callback).toHaveBeenCalledTimes(1);
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not trigger on an outbound call when from is ringing', () => {
|
||||||
|
const { stateManager, callback } = create({
|
||||||
|
trigger: 'call',
|
||||||
|
from: 'ringing',
|
||||||
|
to: 'answered',
|
||||||
|
});
|
||||||
|
|
||||||
|
// An outbound call is answered by construction, so it moves from idle
|
||||||
|
// straight to answered without ringing.
|
||||||
|
stateManager.setState({ call: 'answered' });
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger on an outbound call when only to is given', () => {
|
||||||
|
const { stateManager, callback } = create({ trigger: 'call', to: 'answered' });
|
||||||
|
|
||||||
|
stateManager.setState({ call: 'answered' });
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger when a ringing call is rejected', () => {
|
||||||
|
const { stateManager, callback } = create({
|
||||||
|
trigger: 'call',
|
||||||
|
from: 'ringing',
|
||||||
|
to: 'idle',
|
||||||
|
});
|
||||||
|
|
||||||
|
stateManager.setState({ call: 'ringing' });
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
stateManager.setState({ call: 'idle' });
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not trigger a reject when an answered call ends', () => {
|
||||||
|
const { stateManager, callback } = create({
|
||||||
|
trigger: 'call',
|
||||||
|
from: 'ringing',
|
||||||
|
to: 'idle',
|
||||||
|
});
|
||||||
|
|
||||||
|
stateManager.setState({ call: 'ringing' });
|
||||||
|
stateManager.setState({ call: 'answered' });
|
||||||
|
stateManager.setState({ call: 'idle' });
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger when an answered call is hung up', () => {
|
||||||
|
const { stateManager, callback } = create({
|
||||||
|
trigger: 'call',
|
||||||
|
from: 'answered',
|
||||||
|
to: 'idle',
|
||||||
|
});
|
||||||
|
|
||||||
|
stateManager.setState({ call: 'answered' });
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
stateManager.setState({ call: 'idle' });
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger on both answer and reject with from alone', () => {
|
||||||
|
const answered = create({ trigger: 'call', from: 'ringing' });
|
||||||
|
const rejected = create({ trigger: 'call', from: 'ringing' });
|
||||||
|
|
||||||
|
answered.stateManager.setState({ call: 'ringing' });
|
||||||
|
answered.stateManager.setState({ call: 'answered' });
|
||||||
|
|
||||||
|
rejected.stateManager.setState({ call: 'ringing' });
|
||||||
|
rejected.stateManager.setState({ call: 'idle' });
|
||||||
|
|
||||||
|
expect(answered.callback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(rejected.callback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger on any end with to alone', () => {
|
||||||
|
const { stateManager, callback } = create({ trigger: 'call', to: 'idle' });
|
||||||
|
|
||||||
|
stateManager.setState({ call: 'ringing' });
|
||||||
|
stateManager.setState({ call: 'idle' });
|
||||||
|
stateManager.setState({ call: 'answered' });
|
||||||
|
stateManager.setState({ call: 'idle' });
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match any phase in a from or to list', () => {
|
||||||
|
const { stateManager, callback } = create({
|
||||||
|
trigger: 'call',
|
||||||
|
from: ['ringing', 'answered'],
|
||||||
|
to: ['idle'],
|
||||||
|
});
|
||||||
|
|
||||||
|
stateManager.setState({ call: 'ringing' });
|
||||||
|
stateManager.setState({ call: 'idle' });
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
stateManager.setState({ call: 'answered' });
|
||||||
|
stateManager.setState({ call: 'idle' });
|
||||||
|
expect(callback).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3982,7 +3982,7 @@ describe('should handle version specific upgrades', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('microphone.connected -> call condition', () => {
|
describe('microphone.connected -> call condition', () => {
|
||||||
it('should rewrite connected:true -> call:true in an automation', () => {
|
it('should rewrite connected:true into an active-phase trigger in an automation', () => {
|
||||||
const config = {
|
const config = {
|
||||||
type: 'custom:advanced-camera-card',
|
type: 'custom:advanced-camera-card',
|
||||||
cameras: [{ camera_entity: 'camera.office' }],
|
cameras: [{ camera_entity: 'camera.office' }],
|
||||||
@@ -4000,12 +4000,14 @@ describe('should handle version specific upgrades', () => {
|
|||||||
};
|
};
|
||||||
expect(upgradeConfig(config)).toBeTruthy();
|
expect(upgradeConfig(config)).toBeTruthy();
|
||||||
expect(config.automations[0]).toEqual(
|
expect(config.automations[0]).toEqual(
|
||||||
expect.objectContaining({ triggers: [{ trigger: 'call', call: true }] }),
|
expect.objectContaining({
|
||||||
|
triggers: [{ trigger: 'call', to: ['ringing', 'answered'] }],
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
postUpgradeChecks(config);
|
postUpgradeChecks(config);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should rewrite connected:false -> call:false', () => {
|
it('should rewrite connected:false into an idle-phase trigger', () => {
|
||||||
const config = {
|
const config = {
|
||||||
type: 'custom:advanced-camera-card',
|
type: 'custom:advanced-camera-card',
|
||||||
cameras: [{ camera_entity: 'camera.office' }],
|
cameras: [{ camera_entity: 'camera.office' }],
|
||||||
@@ -4023,7 +4025,7 @@ describe('should handle version specific upgrades', () => {
|
|||||||
};
|
};
|
||||||
expect(upgradeConfig(config)).toBeTruthy();
|
expect(upgradeConfig(config)).toBeTruthy();
|
||||||
expect(config.automations[0]).toEqual(
|
expect(config.automations[0]).toEqual(
|
||||||
expect.objectContaining({ triggers: [{ trigger: 'call', call: false }] }),
|
expect.objectContaining({ triggers: [{ trigger: 'call', to: 'idle' }] }),
|
||||||
);
|
);
|
||||||
postUpgradeChecks(config);
|
postUpgradeChecks(config);
|
||||||
});
|
});
|
||||||
@@ -4074,7 +4076,7 @@ describe('should handle version specific upgrades', () => {
|
|||||||
{
|
{
|
||||||
condition: 'and',
|
condition: 'and',
|
||||||
conditions: [
|
conditions: [
|
||||||
{ condition: 'call', call: true },
|
{ condition: 'call', call: ['ringing', 'answered'] },
|
||||||
{ condition: 'microphone', muted: false },
|
{ condition: 'microphone', muted: false },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -4123,7 +4125,7 @@ describe('should handle version specific upgrades', () => {
|
|||||||
conditions: [
|
conditions: [
|
||||||
{
|
{
|
||||||
condition: 'not',
|
condition: 'not',
|
||||||
conditions: [{ condition: 'call', call: true }],
|
conditions: [{ condition: 'call', call: ['ringing', 'answered'] }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -4153,10 +4155,64 @@ describe('should handle version specific upgrades', () => {
|
|||||||
};
|
};
|
||||||
expect(upgradeConfig(config)).toBeTruthy();
|
expect(upgradeConfig(config)).toBeTruthy();
|
||||||
expect(config.elements[0].conditions).toEqual([
|
expect(config.elements[0].conditions).toEqual([
|
||||||
{ condition: 'call', call: true },
|
{ condition: 'call', call: ['ringing', 'answered'] },
|
||||||
]);
|
]);
|
||||||
expect(config.overrides[0].conditions).toEqual([
|
expect(config.overrides[0].conditions).toEqual([
|
||||||
{ condition: 'call', call: false },
|
{ condition: 'call', call: 'idle' },
|
||||||
|
]);
|
||||||
|
postUpgradeChecks(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep a disabled condition disabled through to the trigger', () => {
|
||||||
|
const config = {
|
||||||
|
type: 'custom:advanced-camera-card',
|
||||||
|
cameras: [{ camera_entity: 'camera.office' }],
|
||||||
|
automations: [
|
||||||
|
{
|
||||||
|
conditions: [{ condition: 'microphone', connected: true, enabled: false }],
|
||||||
|
actions: [
|
||||||
|
{ action: 'fire-dom-event', advanced_camera_card_action: 'live' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(upgradeConfig(config)).toBeTruthy();
|
||||||
|
expect(config.automations[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
triggers: [{ trigger: 'call', to: ['ringing', 'answered'], enabled: false }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
postUpgradeChecks(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep a disabled condition disabled when split into an AND', () => {
|
||||||
|
const config = {
|
||||||
|
type: 'custom:advanced-camera-card',
|
||||||
|
cameras: [{ camera_entity: 'camera.office' }],
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
condition: 'microphone',
|
||||||
|
connected: true,
|
||||||
|
muted: false,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
merge: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(upgradeConfig(config)).toBeTruthy();
|
||||||
|
expect(config.overrides[0].conditions).toEqual([
|
||||||
|
{
|
||||||
|
condition: 'and',
|
||||||
|
conditions: [
|
||||||
|
{ condition: 'call', call: ['ringing', 'answered'] },
|
||||||
|
{ condition: 'microphone', muted: false },
|
||||||
|
],
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
postUpgradeChecks(config);
|
postUpgradeChecks(config);
|
||||||
});
|
});
|
||||||
@@ -4182,13 +4238,55 @@ describe('should handle version specific upgrades', () => {
|
|||||||
// Running upgradeConfig again should not change anything.
|
// Running upgradeConfig again should not change anything.
|
||||||
expect(upgradeConfig(config)).toBeFalsy();
|
expect(upgradeConfig(config)).toBeFalsy();
|
||||||
expect(config.automations[0]).toEqual(
|
expect(config.automations[0]).toEqual(
|
||||||
expect.objectContaining({ triggers: [{ trigger: 'call', call: true }] }),
|
expect.objectContaining({
|
||||||
|
triggers: [{ trigger: 'call', to: ['ringing', 'answered'] }],
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
postUpgradeChecks(config);
|
postUpgradeChecks(config);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('automation conditions -> triggers', () => {
|
describe('automation conditions -> triggers', () => {
|
||||||
|
it('should promote a state condition to a trigger and keep its other fields', () => {
|
||||||
|
const config = {
|
||||||
|
type: 'custom:advanced-camera-card',
|
||||||
|
cameras: [{ camera_entity: 'camera.office' }],
|
||||||
|
automations: [
|
||||||
|
{
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
condition: 'state',
|
||||||
|
entity_id: 'binary_sensor.motion',
|
||||||
|
state: 'on',
|
||||||
|
attribute: 'friendly_name',
|
||||||
|
for: '00:01:00',
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ action: 'fire-dom-event', advanced_camera_card_action: 'live' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(upgradeConfig(config)).toBeTruthy();
|
||||||
|
expect(config.automations[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
triggers: [
|
||||||
|
{
|
||||||
|
trigger: 'state',
|
||||||
|
entity_id: 'binary_sensor.motion',
|
||||||
|
to: 'on',
|
||||||
|
attribute: 'friendly_name',
|
||||||
|
for: '00:01:00',
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
postUpgradeChecks(config);
|
||||||
|
});
|
||||||
|
|
||||||
it('should flatten a composite condition into trigger leaves and keep the composite', () => {
|
it('should flatten a composite condition into trigger leaves and keep the composite', () => {
|
||||||
const config = {
|
const config = {
|
||||||
type: 'custom:advanced-camera-card',
|
type: 'custom:advanced-camera-card',
|
||||||
|
|||||||
@@ -891,7 +891,7 @@ describe('config defaults', () => {
|
|||||||
it('should include all conditions', () => {
|
it('should include all conditions', () => {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
{ condition: 'and', conditions: [{ condition: 'initialized' }] },
|
{ condition: 'and', conditions: [{ condition: 'initialized' }] },
|
||||||
{ condition: 'call', call: true },
|
{ condition: 'call', call: ['ringing', 'answered'] },
|
||||||
{ condition: 'camera', cameras: ['camera.office'] },
|
{ condition: 'camera', cameras: ['camera.office'] },
|
||||||
{ condition: 'display_mode', display_mode: 'single' },
|
{ condition: 'display_mode', display_mode: 'single' },
|
||||||
{ condition: 'expand', expand: true },
|
{ condition: 'expand', expand: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user