feat: Trigger automations on call answered, rejected and hung up (#2591)

This commit is contained in:
Dermot Duffy
2026-07-22 11:11:57 -07:00
committed by GitHub
parent 2400e20134
commit 87ecbc7e45
19 changed files with 737 additions and 97 deletions
@@ -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'),
};
}
}
+9 -9
View File
@@ -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;
}
};
+2 -1
View File
@@ -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).
@@ -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<TriggerOfType<'call'>> {
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,
} 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<T extends Trigger>
implements TriggerEvaluator
{
@@ -24,18 +37,23 @@ export abstract class ConditionStateTriggerBase<T extends Trigger>
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<T extends Trigger>
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;
}
}