feat: Trigger automations on call answered, rejected and hung up (#2591)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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`,
|
||||
// `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;
|
||||
};
|
||||
|
||||
@@ -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<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({
|
||||
call: z.boolean().optional(),
|
||||
call: callPhaseMatchSchema.optional(),
|
||||
});
|
||||
export type CallBase = z.infer<typeof callBaseSchema>;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user