From 87ecbc7e4501a7fc8c10072ea68893aceb753f94 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 22 Jul 2026 11:11:57 -0700 Subject: [PATCH] feat: Trigger automations on call answered, rejected and hung up (#2591) --- docs/configuration/conditions-triggers.md | 60 ++++-- docs/examples.md | 79 +++++++ src/card-controller/call/manager.ts | 11 +- .../conditions/conditions/call.ts | 3 +- src/condition-trigger/conditions/factory.ts | 18 +- src/condition-trigger/conditions/types.ts | 3 +- .../triggers/triggers/call.ts | 19 +- .../triggers/triggers/condition-state-base.ts | 40 +++- src/config/management.ts | 48 ++++- .../schema/condition-trigger/common/call.ts | 10 +- .../conditions/custom/call.ts | 4 +- .../condition-trigger/triggers/custom/call.ts | 14 +- tests/card-controller/call/manager.test.ts | 201 +++++++++++++++++- .../conditions/conditions/call.test.ts | 56 +++-- .../conditions/factory.test.ts | 3 +- .../triggers/factory.test.ts | 2 +- .../triggers/triggers/call.test.ts | 145 +++++++++++-- tests/config/management.test.ts | 116 +++++++++- tests/config/types.test.ts | 2 +- 19 files changed, 737 insertions(+), 97 deletions(-) diff --git a/docs/configuration/conditions-triggers.md b/docs/configuration/conditions-triggers.md index 3ab880e7..aa400fca 100644 --- a/docs/configuration/conditions-triggers.md +++ b/docs/configuration/conditions-triggers.md @@ -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 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 trigger's value is **optional**: give it a value to fire only when the state changes _to_ that value, or **omit it to fire on any change**. (The stock `state` trigger behaves the same way when `from`/`to` are omitted). As a condition the -value keeps its usual per-type meaning, as described below. +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 # A trigger initiates an automation; conditions are then checked. @@ -94,25 +96,56 @@ conditions: ## `call` -Matches whether a [two-way audio](../usage/2-way-audio.md) call is in progress. -As a **condition**, true while the call state matches; as a **trigger**, fires -when it becomes a match (e.g. `call: true` fires when a call starts). +Matches the phase of a [two-way audio](../usage/2-way-audio.md) call. A call is +in one of three phases: + +| 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 # As a condition: conditions: - condition: call - call: true + call: [ringing, answered] # As a trigger: triggers: + # An inbound call was answered. - 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 | -| ----------------------- | ---------------------------------------------------------------------------------------------- | -| `condition` / `trigger` | Must be `call`. | -| `call` | If `true` or `false`, matches when a two-way audio call is or is not in progress respectively. | +| Parameter | Description | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `condition` / `trigger` | Must be `call`. | +| `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` @@ -721,7 +754,7 @@ conditions: - condition: view views: [live] - condition: call - call: true + call: [ringing, answered] - condition: camera cameras: - camera.office @@ -786,7 +819,8 @@ conditions: ```yaml triggers: - trigger: call - call: true + from: ringing + to: answered - trigger: camera cameras: - camera.office diff --git a/docs/examples.md b/docs/examples.md index 81629bbc..f3fcaae8 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -423,6 +423,85 @@ profiles: - 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 `dependencies.cameras` allows events/recordings for other cameras to be shown diff --git a/src/card-controller/call/manager.ts b/src/card-controller/call/manager.ts index 8ea3b11b..dda71863 100644 --- a/src/card-controller/call/manager.ts +++ b/src/card-controller/call/manager.ts @@ -159,9 +159,11 @@ export class CallManager { modifiers: [new SubstreamViewModifier({ stream: callCameraID, camera: parentID })], 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. const call = this._call; if (!call) { @@ -209,6 +211,7 @@ export class CallManager { // change. The `update()` below forces card.ts to re-render and re-read // `getCall()`, propagating the new session to the carousel. this._call = { ...this._call, answered: true }; + this._api.getConditionStateManager().setState({ call: 'answered' }); this._api.getCardElementManager().update(); return true; } @@ -247,7 +250,7 @@ export class CallManager { this._unansweredTimer.stop(); if (this._call) { this._call = null; - this._api.getConditionStateManager().setState({ call: false }); + this._api.getConditionStateManager().setState({ call: 'idle' }); } this._api .getConditionStateManager() @@ -305,7 +308,7 @@ export class CallManager { force: true, }); } - this._api.getConditionStateManager().setState({ call: false }); + this._api.getConditionStateManager().setState({ call: 'idle' }); return true; } diff --git a/src/condition-trigger/conditions/conditions/call.ts b/src/condition-trigger/conditions/conditions/call.ts index d937adb2..ac3cb10e 100644 --- a/src/condition-trigger/conditions/conditions/call.ts +++ b/src/condition-trigger/conditions/conditions/call.ts @@ -1,4 +1,5 @@ import type { CallBase } from '../../../config/schema/condition-trigger/common/call'; +import { arrayify } from '../../../utils/basic'; import type { ConditionsEvaluationResult, ConditionState } from '../types'; import type { ConditionEvaluator } from './types'; @@ -11,7 +12,7 @@ export class CallConditionEvaluator implements ConditionEvaluator { public evaluate(newState?: ConditionState): ConditionsEvaluationResult { return { - result: this._condition.call === (newState?.call ?? false), + result: arrayify(this._condition.call).includes(newState?.call ?? 'idle'), }; } } diff --git a/src/condition-trigger/conditions/factory.ts b/src/condition-trigger/conditions/factory.ts index fe073c94..804c5824 100644 --- a/src/condition-trigger/conditions/factory.ts +++ b/src/condition-trigger/conditions/factory.ts @@ -87,11 +87,12 @@ const triggerHasConditionValue = (trigger: Trigger): boolean => Object.keys(trigger).some((key) => key !== 'trigger' && key !== 'enabled'); // Build the condition evaluator a card trigger checks its changes against, or -// null if it has none -- it then fires on any change of its watched state. A -// trigger has no condition when it carries no value (the any-change form) or -// when it is trigger-only (`config`). Otherwise the trigger and its condition -// share a base schema, so an evaluator (typed on that base) is built directly -// from the trigger -- no discriminator-swap. +// null if it has none. A trigger has no condition when it carries no value (the +// any-change form) or is trigger-only (`config`) -- both then fire on any change +// of the watched state -- or when it matches the transition itself rather than +// its result (`call`), which does its own filtering. Otherwise the trigger and +// its condition share a base schema, so an evaluator (typed on that base) is +// built directly from the trigger -- no discriminator-swap. export const createConditionEvaluatorForTrigger = ( trigger: Trigger, ): ConditionEvaluator | null => { @@ -114,8 +115,6 @@ export const createConditionEvaluatorForTrigger = ( } switch (trigger.trigger) { - case 'call': - return new CallConditionEvaluator(trigger); case 'camera': return new CameraConditionEvaluator(trigger); case 'display_mode': @@ -139,8 +138,9 @@ export const createConditionEvaluatorForTrigger = ( case 'config': return null; default: - // Stock triggers (`state`/`numeric_state`/`template`) evaluate themselves - // and never reuse a card condition through this path. + // Only `call` reaches here. It matches the transition rather than its + // result, so it builds its own `from`/`to` evaluators from the call + // condition. return null; } }; diff --git a/src/condition-trigger/conditions/types.ts b/src/condition-trigger/conditions/types.ts index a20c1be4..0fae2fdc 100644 --- a/src/condition-trigger/conditions/types.ts +++ b/src/condition-trigger/conditions/types.ts @@ -1,6 +1,7 @@ import type { KeysState, MicrophoneState } from '../../card-controller/types'; import type { AdvancedCameraCardView } from '../../config/schema/common/const'; 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 { HomeAssistant } from '../../ha/types'; import type { MediaLoadedInfo } from '../../types'; @@ -16,7 +17,7 @@ import type { MediaLoadedInfo } from '../../types'; // Counterexample: Rebuilding an equivalent function callback each write making // the field look changed when nothing observable did. export interface ConditionState { - call?: boolean; + call?: CallPhase; camera?: string; // The engaged substream for the selected camera (absent when the camera's own // stream is used). diff --git a/src/condition-trigger/triggers/triggers/call.ts b/src/condition-trigger/triggers/triggers/call.ts index ebe0f5c2..bc87c640 100644 --- a/src/condition-trigger/triggers/triggers/call.ts +++ b/src/condition-trigger/triggers/triggers/call.ts @@ -1,9 +1,24 @@ +import { CallConditionEvaluator } from '../../conditions/conditions/call'; 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'; export class CallTrigger extends ConditionStateTriggerBase> { 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 }) }), + }; } } diff --git a/src/condition-trigger/triggers/triggers/condition-state-base.ts b/src/condition-trigger/triggers/triggers/condition-state-base.ts index 3597ca47..4d14b986 100644 --- a/src/condition-trigger/triggers/triggers/condition-state-base.ts +++ b/src/condition-trigger/triggers/triggers/condition-state-base.ts @@ -11,12 +11,25 @@ import type { TriggerEvaluatorContext, } 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, // fire when the watched value (`_getValue`) changes and the new state passes // the trigger's condition, and emit the `acc` payload. The condition is the // matching condition reused as a point-in-time predicate -- so a trigger and // its condition share one definition of meaning. A trigger with no value (any // change), or no matching condition (`config`), has no condition to pass. +// +// 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 implements TriggerEvaluator { @@ -24,18 +37,23 @@ export abstract class ConditionStateTriggerBase protected _context: TriggerEvaluatorContext; private _callback: TriggerCallback | null = null; - private _condition: ConditionEvaluator | null; + private _condition: ConditionEvaluator | null = null; + private _transition: TransitionEvaluators | null = null; constructor(trigger: T, context: TriggerEvaluatorContext) { this._trigger = trigger; this._context = context; - - // The condition the trigger checks each change against. - this._condition = createConditionEvaluatorForTrigger(trigger); } public subscribe(callback: TriggerCallback): void { this._callback = callback; + + // 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); } @@ -51,9 +69,23 @@ export abstract class ConditionStateTriggerBase if (this._condition && !this._condition.evaluate(change.new).result) { 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)); }; // The slice of state this trigger watches; it fires only when this changes. 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; + } } diff --git a/src/config/management.ts b/src/config/management.ts index 44d595d7..4d9210c4 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -591,6 +591,17 @@ const rewriteConditionAsTrigger = (condition: unknown): unknown => { } 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 => { + const rest = { ...condition }; + for (const key of keys) { + delete rest[key]; + } + return rest; + }; + // A `state` condition maps onto the HA state trigger (`state` -> `to`, // `state_not` -> `not_to`). A discriminator-less condition is the bare // picture-element state form -- the only condition that may omit `condition`. @@ -598,19 +609,28 @@ const rewriteConditionAsTrigger = (condition: unknown): unknown => { const entityId = condition['entity_id'] ?? condition['entity']; return { trigger: 'state', + ...withoutKeys('condition', 'entity', 'entity_id', 'state', 'state_not'), ...(entityId !== undefined && { entity_id: entityId }), ...(condition['state'] !== undefined && { to: condition['state'] }), ...(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 // card-specific kinds -- shares its field names with the matching trigger - // (only `state` involves internal field renames), so promoting is just a - // discriminator swap. - const rest = { ...condition }; - delete rest['condition']; - return { trigger: kind, ...rest }; + // (only `state` and `call` involve internal field renames), so promoting is + // just a discriminator swap. + return { trigger: kind, ...withoutKeys('condition') }; }; /** @@ -1427,19 +1447,33 @@ const microphoneConnectedToCallTransform = (data: unknown): boolean => { } 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)) { delete data[key]; } 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['conditions'] = [ - { condition: 'call', call: connected }, + { condition: 'call', call: call }, { condition: 'microphone', muted: muted }, ]; } else { data['condition'] = 'call'; - data['call'] = connected; + data['call'] = call; + } + if (enabled !== undefined) { + data['enabled'] = enabled; } return true; }; diff --git a/src/config/schema/condition-trigger/common/call.ts b/src/config/schema/condition-trigger/common/call.ts index b8dfaf3a..c4aaab62 100644 --- a/src/config/schema/condition-trigger/common/call.ts +++ b/src/config/schema/condition-trigger/common/call.ts @@ -1,6 +1,14 @@ 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; + +// Matches a single phase, or any one of a list of them. +export const callPhaseMatchSchema = callPhaseSchema.or(callPhaseSchema.array()); + export const callBaseSchema = z.object({ - call: z.boolean().optional(), + call: callPhaseMatchSchema.optional(), }); export type CallBase = z.infer; diff --git a/src/config/schema/condition-trigger/conditions/custom/call.ts b/src/config/schema/condition-trigger/conditions/custom/call.ts index c23c12b8..aa499d58 100644 --- a/src/config/schema/condition-trigger/conditions/custom/call.ts +++ b/src/config/schema/condition-trigger/conditions/custom/call.ts @@ -1,11 +1,11 @@ import { z } from 'zod'; -import { callBaseSchema } from '../../common/call'; +import { callBaseSchema, callPhaseMatchSchema } from '../../common/call'; import { conditionBaseSchema } from '../base'; export const callConditionSchema = callBaseSchema .extend(conditionBaseSchema.shape) .extend({ condition: z.literal('call'), - call: z.boolean(), + call: callPhaseMatchSchema, }); diff --git a/src/config/schema/condition-trigger/triggers/custom/call.ts b/src/config/schema/condition-trigger/triggers/custom/call.ts index d53339f8..d9f445c1 100644 --- a/src/config/schema/condition-trigger/triggers/custom/call.ts +++ b/src/config/schema/condition-trigger/triggers/custom/call.ts @@ -1,8 +1,14 @@ import { z } from 'zod'; -import { callBaseSchema } from '../../common/call'; +import { callPhaseMatchSchema } from '../../common/call'; import { triggerBaseSchema } from '../base'; -export const callTriggerSchema = callBaseSchema - .extend(triggerBaseSchema.shape) - .extend({ trigger: z.literal('call') }); +// Unlike the call condition (which matches a specific phase), the trigger +// matches a phase transition: `from` is checked against the phase before the +// 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(), +}); diff --git a/tests/card-controller/call/manager.test.ts b/tests/card-controller/call/manager.test.ts index 84c83368..a69b1003 100644 --- a/tests/card-controller/call/manager.test.ts +++ b/tests/card-controller/call/manager.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom 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 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 type { CardController } from '../../../src/card-controller/controller'; 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 { 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 { AdvancedCameraCardConfig } from '../../../src/config/schema/types'; import { View } from '../../../src/view/view'; +import { createTriggerEvaluatorContext } from '../../condition-trigger/triggers/triggers/test-utils'; import { createCameraConfig, createCameraManager, @@ -145,7 +149,6 @@ describe('start', () => { modifiers: [expect.any(SubstreamViewModifier)], force: true, }); - expect(api.getConditionStateManager().setState).toBeCalledWith({ call: true }); }); 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?.previousView?.view).toBe('live'); 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 () => { @@ -656,7 +657,6 @@ describe('end', () => { modifiers: [expect.any(SubstreamViewModifier)], force: true, }); - expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false }); }); it('should restore the pre-call substream when ending', async () => { @@ -1085,7 +1085,6 @@ describe('initialize / uninitialize', () => { manager.uninitialize(); expect(manager.isActive()).toBe(false); - expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false }); }); 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 // condition listener and end the call before `start()` returns. Verify the // 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); 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, // nulling the session before `start()` finishes. - if (state.call === true) { + if (state.call === 'ringing') { listener({ old: { camera: 'camera.office', view: 'live' }, change: { view: 'clips' }, @@ -1683,3 +1682,187 @@ describe('uninitialize during in-flight start', () => { 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; + 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); + }); +}); diff --git a/tests/condition-trigger/conditions/conditions/call.test.ts b/tests/condition-trigger/conditions/conditions/call.test.ts index 9000e819..066b2fce 100644 --- a/tests/condition-trigger/conditions/conditions/call.test.ts +++ b/tests/condition-trigger/conditions/conditions/call.test.ts @@ -10,27 +10,53 @@ describe('call condition', () => { expect(() => callConditionSchema.parse({ condition: 'call' })).toThrow(); }); - it('should match when call is true', () => { - const evaluator = createConditionEvaluator( - { condition: 'call' as const, call: true }, - createEvaluatorContext(), - ); - - expect(evaluator.evaluate({}).result).toBeFalsy(); - expect(evaluator.evaluate({ call: true }).result).toBeTruthy(); - expect(evaluator.evaluate({ call: false }).result).toBeFalsy(); + it('should reject a phase that does not exist', () => { + expect(() => + callConditionSchema.parse({ condition: 'call', call: 'hungup' }), + ).toThrow(); }); - it('should match when call is false', () => { + it('should match the ringing phase', () => { 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(), ); - // With no state.call published, the bare condition matches `false`, - // so `call: false` is satisfied. expect(evaluator.evaluate({}).result).toBeTruthy(); - expect(evaluator.evaluate({ call: true }).result).toBeFalsy(); - expect(evaluator.evaluate({ call: false }).result).toBeTruthy(); + expect(evaluator.evaluate({ call: 'idle' }).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(); }); }); diff --git a/tests/condition-trigger/conditions/factory.test.ts b/tests/condition-trigger/conditions/factory.test.ts index 49e70a84..7b321119 100644 --- a/tests/condition-trigger/conditions/factory.test.ts +++ b/tests/condition-trigger/conditions/factory.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { CallConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/call'; import { CameraConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/camera'; import { DisplayModeConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/display-mode'; import { ExpandConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/expand'; @@ -19,7 +18,6 @@ type ConditionEvaluatorConstructor = new (...args: never[]) => ConditionEvaluato describe('createConditionEvaluatorForTrigger', () => { it.each<[Trigger, ConditionEvaluatorConstructor]>([ - [{ trigger: 'call', call: true }, CallConditionEvaluator], [{ trigger: 'camera', cameras: ['front'] }, CameraConditionEvaluator], [{ trigger: 'display_mode', display_mode: 'single' }, DisplayModeConditionEvaluator], [{ trigger: 'expand', expand: true }, ExpandConditionEvaluator], @@ -40,6 +38,7 @@ describe('createConditionEvaluatorForTrigger', () => { it.each<[string, Trigger]>([ ['a valueless trigger fires on any change', { trigger: 'fullscreen' }], ['config has no matching condition', { trigger: 'config', paths: ['menu.style'] }], + ['call matches the change itself', { trigger: 'call', from: 'ringing' }], [ 'stock triggers evaluate themselves', { trigger: 'state', entity_id: 'binary_sensor.x' }, diff --git a/tests/condition-trigger/triggers/factory.test.ts b/tests/condition-trigger/triggers/factory.test.ts index 3732f159..467adad2 100644 --- a/tests/condition-trigger/triggers/factory.test.ts +++ b/tests/condition-trigger/triggers/factory.test.ts @@ -42,7 +42,7 @@ describe('createTriggerEvaluator', () => { [{ trigger: 'state', entity_id: 'binary_sensor.x' }, StateTrigger], [{ trigger: 'numeric_state', entity_id: 'sensor.x', above: 5 }, NumericStateTrigger], [{ trigger: 'template', value_template: '{{ true }}' }, TemplateTrigger], - [{ trigger: 'call', call: true }, CallTrigger], + [{ trigger: 'call', to: 'answered' }, CallTrigger], [{ trigger: 'camera', cameras: ['front'] }, CameraTrigger], [{ trigger: 'config' }, ConfigTrigger], [{ trigger: 'display_mode', display_mode: 'single' }, DisplayModeTrigger], diff --git a/tests/condition-trigger/triggers/triggers/call.test.ts b/tests/condition-trigger/triggers/triggers/call.test.ts index 3eba8455..a2cfc00e 100644 --- a/tests/condition-trigger/triggers/triggers/call.test.ts +++ b/tests/condition-trigger/triggers/triggers/call.test.ts @@ -18,31 +18,152 @@ describe('CallTrigger', () => { 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' }); - // Absent (undefined) is equivalent to false, so this is not a change. - stateManager.setState({ call: false }); + // Absent (undefined) is equivalent to idle, so this is not a change. + stateManager.setState({ call: 'idle' }); expect(callback).not.toHaveBeenCalled(); - stateManager.setState({ call: true }); + stateManager.setState({ call: 'ringing' }); expect(callback).toHaveBeenCalledTimes(1); }); - it('should trigger only on changes to the given value', () => { - const { stateManager, callback } = create({ trigger: 'call', call: true }); - stateManager.setState({ call: true }); - stateManager.setState({ call: false }); + it('should trigger on any phase change without from or to', () => { + const { stateManager, callback } = create({ trigger: 'call' }); + + 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); }); - it('should trigger on the falling edge to a false value', () => { - const { stateManager, callback } = create({ trigger: 'call', call: false }); + it('should trigger when an inbound call is answered', () => { + const { stateManager, callback } = create({ + trigger: 'call', + from: 'ringing', + to: 'answered', + }); - stateManager.setState({ call: true }); + stateManager.setState({ call: 'ringing' }); expect(callback).not.toHaveBeenCalled(); - stateManager.setState({ call: false }); + stateManager.setState({ call: 'answered' }); 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); + }); }); diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index d4522f2c..0cd69051 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -3982,7 +3982,7 @@ describe('should handle version specific upgrades', () => { }); 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 = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -4000,12 +4000,14 @@ describe('should handle version specific upgrades', () => { }; expect(upgradeConfig(config)).toBeTruthy(); expect(config.automations[0]).toEqual( - expect.objectContaining({ triggers: [{ trigger: 'call', call: true }] }), + expect.objectContaining({ + triggers: [{ trigger: 'call', to: ['ringing', 'answered'] }], + }), ); postUpgradeChecks(config); }); - it('should rewrite connected:false -> call:false', () => { + it('should rewrite connected:false into an idle-phase trigger', () => { const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], @@ -4023,7 +4025,7 @@ describe('should handle version specific upgrades', () => { }; expect(upgradeConfig(config)).toBeTruthy(); expect(config.automations[0]).toEqual( - expect.objectContaining({ triggers: [{ trigger: 'call', call: false }] }), + expect.objectContaining({ triggers: [{ trigger: 'call', to: 'idle' }] }), ); postUpgradeChecks(config); }); @@ -4074,7 +4076,7 @@ describe('should handle version specific upgrades', () => { { condition: 'and', conditions: [ - { condition: 'call', call: true }, + { condition: 'call', call: ['ringing', 'answered'] }, { condition: 'microphone', muted: false }, ], }, @@ -4123,7 +4125,7 @@ describe('should handle version specific upgrades', () => { conditions: [ { 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(config.elements[0].conditions).toEqual([ - { condition: 'call', call: true }, + { condition: 'call', call: ['ringing', 'answered'] }, ]); 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); }); @@ -4182,13 +4238,55 @@ describe('should handle version specific upgrades', () => { // Running upgradeConfig again should not change anything. expect(upgradeConfig(config)).toBeFalsy(); expect(config.automations[0]).toEqual( - expect.objectContaining({ triggers: [{ trigger: 'call', call: true }] }), + expect.objectContaining({ + triggers: [{ trigger: 'call', to: ['ringing', 'answered'] }], + }), ); postUpgradeChecks(config); }); }); 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', () => { const config = { type: 'custom:advanced-camera-card', diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index a702ad1f..401e16a6 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -891,7 +891,7 @@ describe('config defaults', () => { it('should include all conditions', () => { const conditions = [ { condition: 'and', conditions: [{ condition: 'initialized' }] }, - { condition: 'call', call: true }, + { condition: 'call', call: ['ringing', 'answered'] }, { condition: 'camera', cameras: ['camera.office'] }, { condition: 'display_mode', display_mode: 'single' }, { condition: 'expand', expand: true },