feat: Add support for inbound "calls" from triggers (#2500)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent f4c686c298
commit 15e335a647
39 changed files with 2915 additions and 133 deletions
@@ -6,6 +6,9 @@ export class CallStartAction extends AdvancedCameraCardAction<CallStartActionCon
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getCallManager().start(this._action.camera, this._action.stream);
await api.getCallManager().start({
cameraID: this._action.camera,
streamID: this._action.stream,
});
}
}
+180 -47
View File
@@ -1,22 +1,29 @@
import { createNotificationFromText } from '../../components-lib/notification/factory';
import { ConditionStateChange } from '../../conditions/types';
import { localize } from '../../localize/localize';
import { Timer } from '../../utils/timer';
import { getStreamCameraID } from '../../view/substream';
import { View } from '../../view/view';
import { CardCallAPI } from '../types';
import { SubstreamViewModifier } from '../view/modifiers/substream';
import { Ringtone } from './ringtone';
import { CallSession } from './types';
export class CallManager {
private _api: CardCallAPI;
private _call: CallSession | null = null;
private _ringtone = new Ringtone();
private _unansweredTimer = new Timer();
constructor(api: CardCallAPI) {
this._api = api;
}
// A call runs on the live view of a specific camera. Observe the
// condition state so the call can be ended if the view, camera or engaged
// substream moves off what the call started on.
public initialize(): void {
// A call runs on the live view of a specific camera. The listener watches
// condition state so the call can be ended when the view, camera, or
// engaged substream moves off what the call started on -- and so an inbound
// call can register the user's "answer" (microphone un-mute).
this._api.getConditionStateManager().addListener(this._handleConditionStateChange);
}
@@ -36,12 +43,19 @@ export class CallManager {
// Lifecycle
// =========================================================================
public async start(cameraID?: string, streamID?: string): Promise<void> {
// Returns true iff the requested call is active after this returns, false
// otherwise.
public async start(options?: {
cameraID?: string;
streamID?: string;
inbound?: boolean;
}): Promise<boolean> {
const inbound = !!options?.inbound;
const view = this._api.getViewManager().getView();
const parentID = cameraID ?? view?.camera;
const parentID = options?.cameraID ?? view?.camera;
if (!view || !parentID) {
return;
return false;
}
if (
@@ -51,15 +65,15 @@ export class CallManager {
.getCameraIDsWithCapability('live')
.has(parentID)
) {
this._notifyError('error.call_invalid_target');
return;
this._notifyError('error.call_invalid_target', inbound);
return false;
}
const targetID = streamID
? this._validateStream(parentID, streamID)
: this._pickDefaultTarget(view, parentID);
const targetID = options?.streamID
? this._validateStream(parentID, options.streamID, inbound)
: this._pickDefaultTarget(view, parentID, inbound);
if (!targetID) {
return;
return false;
}
// `callCameraID` is the substream carrying the call audio -- absent when
@@ -72,17 +86,17 @@ export class CallManager {
existingCall.cameraID === parentID &&
existingCall.callCameraID === callCameraID
) {
// This exact call (same parent camera and stream) is already running; a
// repeat request must not disrupt it.
return;
// This exact call (same parent camera and stream) is already running --
// the caller has what they asked for.
return true;
}
if (!this._microphonePreflight()) {
return;
if (!this._microphonePreflight(inbound)) {
return false;
}
if (!(await this._connectMicrophone())) {
return;
if (!(await this._connectMicrophone(inbound))) {
return false;
}
// Store the previous view so it can be restored later. A call superseding
@@ -94,17 +108,26 @@ export class CallManager {
const needsNavigation = !view.is('live') || view.camera !== parentID;
// Any other call in progress is superseded. Ended here -- after the
// preflight passes -- so a failed preflight leaves the existing call
// intact.
// An inbound call must not yank the user away from a call they care about.
// Skip the new start request if the existing call is either manual
// (user-initiated) or already answered (user engaged). Newer inbound rings
// still replace older unanswered ones.
if (existingCall) {
if (inbound && (existingCall.answered || !existingCall.inbound)) {
return false;
}
this._end(false);
}
// An already-unmuted mic is treated as "answered" for an inbound call.
const answered = inbound && !this._api.getMicrophoneManager().isMuted();
this._call = {
cameraID: parentID,
...(callCameraID && { callCameraID }),
previousView,
inbound,
answered,
};
this._api.getViewManager().setViewByParameters({
@@ -115,12 +138,79 @@ export class CallManager {
force: true,
});
this._api.getConditionStateManager().setState({ call: true });
// Re-read the session as the listeners triggered by `call: true` may have
// already have changed the state.
const call = this._call;
if (!call) {
return false;
}
// Ring only if still unanswered.
const callConfig = this._api.getConfigManager().getConfig()?.live.controls.call;
const ringtoneConfig = callConfig?.ringtone;
if (
call.inbound &&
!call.answered &&
ringtoneConfig &&
ringtoneConfig.type !== 'none'
) {
this._ringtone.start(ringtoneConfig);
}
// Arm the unanswered-call timeout: if the inbound call rings for this long
// without being answered, end it.
const timeoutSeconds = callConfig?.unanswered_timeout_seconds ?? 0;
if (call.inbound && !call.answered && timeoutSeconds > 0) {
this._unansweredTimer.start(timeoutSeconds, () => this.end());
}
return true;
}
// Ends the call and returns to the view that was showing before
// `call_start` -- the user-facing `call_end`.
public end(): void {
this._end(true);
// Ends the call and returns to the pre-call view. Returns true iff a call was
// actually ended (false when there's no active call).
public end(): boolean {
return this._end(true);
}
// Ends the active call iff every supplied predicate matches the session.
// Returns true iff a call was actually ended.
public endIf(options: {
cameraID?: string;
inbound?: boolean;
answered?: boolean;
}): boolean {
if (!this._call) {
return false;
}
if (options.cameraID !== undefined && this._call.cameraID !== options.cameraID) {
return false;
}
if (options.inbound !== undefined && this._call.inbound !== options.inbound) {
return false;
}
if (options.answered !== undefined && this._call.answered !== options.answered) {
return false;
}
return this.end();
}
// Tears down everything `initialize()` set up: stops any in-flight ringtone
// and unanswered timer, drops the active call session, clears the call
// condition state, and de-registers the condition-state listener. Driven by
// the card element lifecycle: called from `elementDisconnected`.
//
// Safe to re-initialize afterwards via `initialize()`.
public uninitialize(): void {
this._ringtone.stop();
this._unansweredTimer.stop();
if (this._call) {
this._call = null;
this._api.getConditionStateManager().setState({ call: false });
}
this._api
.getConditionStateManager()
.removeListener(this._handleConditionStateChange);
}
// `restoreView` navigates back to the pre-call view -- the symmetric
@@ -128,13 +218,18 @@ export class CallManager {
// is `false` for auto-ends (navigating away, camera/substream change), where
// the user has already chosen a destination and the pre-call view is
// deliberately not reinstated; only the manager's own auto-end paths pass it.
private _end(restoreView: boolean): void {
// Returns true iff a call was actually ended.
private _end(restoreView: boolean): boolean {
if (!this._call) {
return;
return false;
}
const call = this._call;
const previousView = call.previousView;
// Silence any ringtone before the navigation.
this._ringtone.stop();
this._unansweredTimer.stop();
// Clear the session first: ending the call dispatches a view change, and
// the resulting condition-state change must not see this (now-ending) call
// and recurse.
@@ -171,17 +266,43 @@ export class CallManager {
});
}
this._api.getConditionStateManager().setState({ call: false });
return true;
}
// End the call once it can no longer be conducted from where it started: the
// view leaves `live` (the call overlay exists only there, so the call would
// otherwise be stranded with no controls), the selected camera changes, or
// the engaged substream moves off the call's audio source. Covers navigation
// and `live_substream_*` actions taken while `live.controls.call.lock` is
// disabled, as well as any forced view change.
// Watches condition state for two transitions during an active call:
//
// 1. End the call once it can no longer be conducted from where it started
// (e.g. view change). Only react to changes in view/camera/substream
// themselves -- not to unrelated state updates (e.g. `mediaLoadedInfo`)
// that may arrive before the view-manager's own state update.
//
// 2. Register an inbound call as "answered" the first time the microphone
// un-mutes during the call -- a muted->unmuted transition. Idempotent:
// once answered we never flip back, so re-muting later does not undo it.
// Answering also silences the ringtone.
private _handleConditionStateChange = (stateChange: ConditionStateChange): void => {
if (!this._call) {
return;
}
if (
this._call &&
this._call.inbound &&
!this._call.answered &&
stateChange.change.microphone &&
stateChange.new.microphone?.muted === false &&
stateChange.old.microphone?.muted !== false
) {
this._call.answered = true;
this._ringtone.stop();
this._unansweredTimer.stop();
}
const viewRelevantChange =
stateChange.change.view !== undefined ||
stateChange.change.camera !== undefined ||
stateChange.change.substreamID !== undefined;
if (
viewRelevantChange &&
(stateChange.new.view !== 'live' ||
stateChange.new.camera !== this._call.cameraID ||
stateChange.new.substreamID !== this._call.callCameraID)
@@ -194,7 +315,11 @@ export class CallManager {
// Helpers
// =========================================================================
private _notifyError(messageKey: string): void {
private _notifyError(messageKey: string, inbound: boolean): void {
if (inbound) {
// Don't show errors on inbound calls.
return;
}
this._api.getNotificationManager().setNotification(
createNotificationFromText(localize(messageKey), {
heading: { text: localize('error.call_unavailable_heading') },
@@ -202,25 +327,25 @@ export class CallManager {
);
}
// Returns `true` to proceed, `false` to abort (with a notification already
// surfaced).
private _microphonePreflight(): boolean {
// Returns `true` to proceed, `false` to abort (with a notification surfaced
// unless `inbound` is set).
private _microphonePreflight(inbound: boolean): boolean {
const microphoneManager = this._api.getMicrophoneManager();
if (!microphoneManager.isSupported()) {
this._notifyError('error.call_microphone_unsupported');
this._notifyError('error.call_microphone_unsupported', inbound);
return false;
}
if (microphoneManager.isForbidden()) {
this._notifyError('error.call_microphone_forbidden');
this._notifyError('error.call_microphone_forbidden', inbound);
return false;
}
return true;
}
private async _connectMicrophone(): Promise<boolean> {
private async _connectMicrophone(inbound: boolean): Promise<boolean> {
const microphoneManager = this._api.getMicrophoneManager();
if (microphoneManager.isConnected()) {
return true;
@@ -229,7 +354,7 @@ export class CallManager {
await microphoneManager.connect();
return true;
} catch {
this._notifyError('error.call_microphone_forbidden');
this._notifyError('error.call_microphone_forbidden', inbound);
return false;
}
}
@@ -243,13 +368,17 @@ export class CallManager {
// Validate an explicitly-requested call stream: it must be `cameraID` itself
// or one of its 2-way-audio dependencies.
private _validateStream(cameraID: string, streamID: string): string | null {
private _validateStream(
cameraID: string,
streamID: string,
inbound: boolean,
): string | null {
const eligibleCameraIDs = this._api
.getCameraManager()
.getStore()
.getAllDependentCameras(cameraID, '2-way-audio');
if (!eligibleCameraIDs.has(streamID)) {
this._notifyError('error.call_invalid_target');
this._notifyError('error.call_invalid_target', inbound);
return null;
}
return streamID;
@@ -259,7 +388,11 @@ export class CallManager {
// it's call-capable (keeps the user's substream selection intact). Else
// fall back to the parent itself (if call-capable) or the first eligible
// dependency. Returns null + notification if neither path finds a target.
private _pickDefaultTarget(view: View, parentID: string): string | null {
private _pickDefaultTarget(
view: View,
parentID: string,
inbound: boolean,
): string | null {
const currentStream = getStreamCameraID(view, parentID);
if (currentStream && this._hasCallCapability(currentStream)) {
return currentStream;
@@ -272,7 +405,7 @@ export class CallManager {
.getAllDependentCameras(parentID, '2-way-audio'),
];
if (!candidates.length) {
this._notifyError('error.call_no_two_way_audio');
this._notifyError('error.call_no_two_way_audio', inbound);
return null;
}
return candidates[0];
+87
View File
@@ -0,0 +1,87 @@
import { RingtoneConfig } from '../../config/schema/live';
import { ArpeggioTone } from './tones/arpeggio';
import { ChimeTone } from './tones/chime';
import { CustomTone } from './tones/custom';
import { MelodyTone } from './tones/melody';
import { Tone } from './tones/types';
import { WestminsterTone } from './tones/westminster';
// Module-level singleton lock: only one `Ringtone` plays at a time across all
// card instances on the page. The HA dashboard can render multiple card
// instances simultaneously (e.g. dashboard card + editor preview, or the same
// card placed twice), all of which may independently react to the same trigger
// state change -- with no lock, every instance would start its own AudioContext
// and the audio would layer. First-to-start wins; subsequent `start()` calls
// from other holders are no-ops until the active one releases via `stop()`. The
// lock auto-recovers from a holder that forgot to release (e.g. a controller
// GC'd without disconnect cleanup) via the `isPlaying()` sweep below.
const sharedLock = new Set<Ringtone>();
export class Ringtone {
private _tone: Tone | null = null;
private readonly lock: Set<Ringtone>;
// The `lock` parameter defaults to the module-level singleton so production
// callers (`new Ringtone()`) get cross-instance coordination automatically.
// Test callers can pass `new Set()` per test to isolate state without
// touching a process-wide value.
constructor(lock: Set<Ringtone> = sharedLock) {
this.lock = lock;
}
public start(config: RingtoneConfig): void {
if (this._tone) {
return;
}
// Drop any stale holders before checking the lock.
for (const other of this.lock) {
if (!other.isPlaying()) {
this.lock.delete(other);
}
}
// Another tone is already ringing.
if (this.lock.size) {
return;
}
this._tone = this._createTone(config);
if (this._tone) {
this.lock.add(this);
this._tone.start(() => this._handleToneEnd());
}
}
public stop(): void {
this._tone?.stop();
this._tone = null;
this.lock.delete(this);
}
public isPlaying(): boolean {
return !!this._tone;
}
private _handleToneEnd(): void {
this._tone = null;
this.lock.delete(this);
}
private _createTone(config: RingtoneConfig): Tone | null {
switch (config.type) {
case 'chime':
return new ChimeTone(config.repeat);
case 'westminster':
return new WestminsterTone(config.repeat);
case 'arpeggio':
return new ArpeggioTone(config.repeat);
case 'melody':
return new MelodyTone(config.repeat);
case 'custom':
return config.url ? new CustomTone(config.url, config.repeat) : null;
case 'none':
return null;
}
}
}
@@ -0,0 +1,23 @@
import { BellStrikeOptions, BellTone } from './bell';
const PLUCK: BellStrikeOptions = {
sparklePeak: 0.05,
fundPeak: 0.13,
humPeak: 0.04,
sparkleDecay: 0.15,
fundDecay: 0.3,
humDecay: 0.5,
};
// Three quick descending notes -- G5, E5, C5 -- 0.25s apart. Shorter decays
// than the other bell tones since the arpeggio's character is lightness and
// pace.
export class ArpeggioTone extends BellTone {
protected _play(): void {
const t0 = this._currentTime;
this._strike(783.99, t0 + 0.0, PLUCK); // G5
this._strike(659.25, t0 + 0.25, PLUCK); // E5
this._strike(523.25, t0 + 0.5, PLUCK); // C5
this._scheduleNext(3);
}
}
+115
View File
@@ -0,0 +1,115 @@
import { Timer } from '../../../utils/timer';
import { RingtoneFinishedHandler, Tone, ToneEnvelope } from './types';
// Shared scaffolding for tones generated via the Web Audio API: owns the
// AudioContext lifecycle, the repeat timer, and the bell-shaped note envelope.
// Subclasses implement `_play()` to define one iteration of their pattern, and
// call `_scheduleNext()` to loop.
//
// `repeat` caps how many iterations are played per `start()`. `0` means loop
// indefinitely; otherwise the tone schedules a final no-op timer to let the
// last iteration's decay tail finish audibly, then fires `finishedHandler` and
// self-stops.
export abstract class GeneratedTone implements Tone {
private _context: AudioContext | null = null;
private _timer = new Timer();
private _finishedHandler: RingtoneFinishedHandler | null = null;
private readonly _repeat: number;
private _remaining = 0;
constructor(repeat: number) {
this._repeat = repeat;
}
public start(finishedHandler?: RingtoneFinishedHandler): void {
if (this._context) {
return;
}
try {
this._context = new AudioContext();
} catch {
this._context = null;
// Treat AudioContext construction failure as natural completion so the
// caller can release any lock it holds on our behalf -- otherwise the
// orchestrator can't tell silent failure from active playback.
finishedHandler?.();
return;
}
this._finishedHandler = finishedHandler ?? null;
this._remaining = this._repeat;
this._play();
}
public stop(): void {
this._timer.stop();
this._context?.close().catch(() => {});
this._context = null;
// Suppress any natural-completion callback -- the caller asked to stop.
this._finishedHandler = null;
}
// Current AudioContext time. Subclasses only read this from inside `_play()`
// where the context is always set.
protected get _currentTime(): number {
/* istanbul ignore next: _currentTime is only read by subclasses from
_play() during an active context -- @preserve */
return this._context?.currentTime ?? 0;
}
// Schedule the next iteration of `_play()`. Subclasses call this at the end
// of their pattern to loop. No-ops if the context has already been closed so
// a stopped tone can never re-arm its loop.
protected _scheduleNext(intervalSeconds: number): void {
/* istanbul ignore next: defensive guard against a subclass calling
_scheduleNext after stop() — JS single-threading makes this unreachable
from the existing subclasses -- @preserve */
if (!this._context) {
return;
}
if (this._repeat > 0 && --this._remaining <= 0) {
// Schedule one last wait for the decay tail.
this._timer.start(intervalSeconds, () => {
const finishedHandler = this._finishedHandler;
this.stop();
finishedHandler?.();
});
return;
}
this._timer.start(intervalSeconds, () => {
/* istanbul ignore next: Timer.stop() cancels pending callbacks, so this
re-entry guard is unreachable in practice -- @preserve */
if (!this._context) {
return;
}
this._play();
});
}
// Plays one note: a smooth tone that rises to peak volume and then fades.
protected _playNote(freq: number, when: number, envelope: ToneEnvelope): void {
/* istanbul ignore next: _playNote is only called by subclasses from
_play() during an active context -- @preserve */
if (!this._context) {
return;
}
const oscillator = this._context.createOscillator();
const gain = this._context.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = freq;
oscillator.connect(gain);
gain.connect(this._context.destination);
gain.gain.setValueAtTime(0, when);
gain.gain.linearRampToValueAtTime(envelope.peak, when + envelope.attack);
gain.gain.setTargetAtTime(0, when + envelope.attack, envelope.decayTau);
oscillator.start(when);
oscillator.stop(when + envelope.hold);
}
// One iteration of the pattern. Implementations should call `_playNote(...)`
// for each note and finish with `_scheduleNext(...)` to loop.
protected abstract _play(): void;
}
+50
View File
@@ -0,0 +1,50 @@
import { GeneratedTone } from './base';
// Per-layer volume and decay tuning for a single bell strike. All fields are
// optional with sensible defaults; subclasses override only the layers they
// want to tune.
export interface BellStrikeOptions {
sparklePeak?: number;
fundPeak?: number;
humPeak?: number;
sparkleDecay?: number;
fundDecay?: number;
humDecay?: number;
}
// Base for tones whose pattern is a series of single-note bell strikes. Each
// strike stacks a sparkle (one octave above the fundamental), the
// fundamental, and a hum (one octave below), with differential decay --
// sparkle fades fastest, hum lingers -- for the natural bell evolution.
//
// Subclasses define the pattern by calling `_strike(freq, when, options)` at
// the right moments; this base handles the three-layer stacking.
export abstract class BellTone extends GeneratedTone {
protected _strike(freq: number, when: number, options?: BellStrikeOptions): void {
const sparklePeak = options?.sparklePeak ?? 0.06;
const fundPeak = options?.fundPeak ?? 0.14;
const humPeak = options?.humPeak ?? 0.05;
const sparkleDecay = options?.sparkleDecay ?? 0.3;
const fundDecay = options?.fundDecay ?? 0.6;
const humDecay = options?.humDecay ?? 1.0;
this._playNote(freq * 2, when, {
peak: sparklePeak,
attack: 0.005,
decayTau: sparkleDecay,
hold: sparkleDecay * 4,
});
this._playNote(freq, when, {
peak: fundPeak,
attack: 0.005,
decayTau: fundDecay,
hold: fundDecay * 4,
});
this._playNote(freq / 2, when, {
peak: humPeak,
attack: 0.005,
decayTau: humDecay,
hold: humDecay * 3,
});
}
}
+30
View File
@@ -0,0 +1,30 @@
import { BellTone } from './bell';
// A classic doorbell "DING DOOOOONG" -- two strikes, Eb5 down to B4 (a major
// third). Each strike is a bell stack: sparkle one octave above, fundamental,
// hum one octave below, with differential decay (sparkle fades fastest, hum
// lingers).
export class ChimeTone extends BellTone {
protected _play(): void {
const t0 = this._currentTime;
// DING -- Eb5.
this._strike(622.25, t0, {
sparklePeak: 0.1,
fundPeak: 0.22,
humPeak: 0.08,
sparkleDecay: 0.3,
fundDecay: 0.8,
humDecay: 1.2,
});
// DOOOOONG -- B4, louder and longer.
this._strike(493.88, t0 + 0.5, {
sparklePeak: 0.11,
fundPeak: 0.28,
humPeak: 0.1,
sparkleDecay: 0.5,
fundDecay: 1.3,
humDecay: 1.8,
});
this._scheduleNext(5);
}
}
+87
View File
@@ -0,0 +1,87 @@
import { RingtoneFinishedHandler, Tone } from './types';
export class CustomTone implements Tone {
private _audio: HTMLAudioElement | null = null;
private _finishedHandler: RingtoneFinishedHandler | null = null;
private readonly _url: string;
private readonly _repeat: number;
private _remaining = 0;
constructor(url: string, repeat: number) {
this._url = url;
this._repeat = repeat;
}
public start(finishedHandler?: RingtoneFinishedHandler): void {
if (this._audio) {
return;
}
try {
this._audio = new Audio(this._url);
} catch {
this._audio = null;
// Treat constructor failure as natural completion so the caller can
// release any lock it holds on our behalf.
finishedHandler?.();
return;
}
this._finishedHandler = finishedHandler ?? null;
if (this._repeat === 0) {
this._audio.loop = true;
} else {
this._remaining = this._repeat;
this._audio.addEventListener('ended', this._handleEnded);
}
this._playAudio();
}
public stop(): void {
if (this._audio) {
this._audio.removeEventListener('ended', this._handleEnded);
this._audio.pause();
this._audio = null;
}
// Suppress any natural-completion callback -- the caller asked to stop.
this._finishedHandler = null;
}
private _handleEnded = (): void => {
/* istanbul ignore next: stop() removes this listener before nulling
_audio, so the handler can't fire with a null _audio -- @preserve */
if (!this._audio) {
return;
}
if (--this._remaining > 0) {
this._playAudio();
return;
}
this._finishNaturally();
};
private _playAudio(): void {
/* istanbul ignore next: callers (start, _handleEnded) only invoke
_playAudio when _audio is non-null -- @preserve */
if (!this._audio) {
return;
}
this._audio.currentTime = 0;
this._audio.play().catch(
// On `play()` rejection (autoplay block, network failure, decode error) no
// `ended` event will arrive, so signal completion ourselves to avoid leaks
// at the higher level (e.g. the ringtone lock).
() => this._finishNaturally(),
);
}
private _finishNaturally(): void {
const finishedHandler = this._finishedHandler;
this.stop();
finishedHandler?.();
}
}
+56
View File
@@ -0,0 +1,56 @@
import { GeneratedTone } from './base';
// A short melodic phrase: a I-V-I cadence in C major. Three triads played in
// sequence with a bell envelope -- each chord rings as the next begins, so
// the harmonies blend briefly before resolving home an octave higher. Each
// chord is framed bell-stack style with a sparkle an octave above the
// highest note and a hum an octave below the lowest, while the chord notes
// themselves are the fundamentals.
export class MelodyTone extends GeneratedTone {
protected _play(): void {
const t0 = this._currentTime;
// I -- C major: C5 + E5 + G5 (root C, sparkle G6, hum C4).
this._strike([523.25, 659.25, 783.99], 1567.98, 261.63, t0 + 0.0);
// V -- G major: G4 + B4 + D5 (root G, sparkle D6, hum G3).
this._strike([392.0, 493.88, 587.33], 1174.66, 196.0, t0 + 1.0);
// I -- C major higher: E5 + G5 + C6 (sparkle C7, hum E4), longer tail.
this._strike([659.25, 783.99, 1046.5], 2093.0, 329.63, t0 + 2.0, {
fundDecay: 0.9,
humDecay: 1.4,
});
this._scheduleNext(6);
}
private _strike(
chordFreqs: number[],
sparkleFreq: number,
humFreq: number,
when: number,
options?: { fundDecay?: number; humDecay?: number },
): void {
const fundDecay = options?.fundDecay ?? 0.6;
const humDecay = options?.humDecay ?? 1.1;
this._playNote(sparkleFreq, when, {
peak: 0.05,
attack: 0.005,
decayTau: 0.4,
hold: 1.6,
});
for (const freq of chordFreqs) {
this._playNote(freq, when, {
peak: 0.1,
attack: 0.005,
decayTau: fundDecay,
hold: fundDecay * 4,
});
}
this._playNote(humFreq, when, {
peak: 0.05,
attack: 0.005,
decayTau: humDecay,
hold: humDecay * 3,
});
}
}
+24
View File
@@ -0,0 +1,24 @@
export type RingtoneFinishedHandler = () => void;
// A playable inbound-call notification sound. Implementations may loop a
// generated pattern, play a single file, or do nothing -- `start()` is the only
// entry point and `stop()` halts whatever is in flight.
export interface Tone {
// Handler is called once when a tone exhausts its configured play count
// naturally (i.e. completes the last iteration's audible tail). Does NOT fire
// when `stop()` is invoked externally -- so callers can distinguish "tone
// finished playing" from "we asked it to stop".
start(finishedHandler?: RingtoneFinishedHandler): void;
stop(): void;
}
// A single note's volume shape over time: rises to `peak` over `attack`
// seconds, then fades. `decayTau` controls the fade speed (smaller = faster).
// `hold` sets the note's total duration -- pick a value large enough for the
// fade to be inaudible by the end.
export interface ToneEnvelope {
peak: number;
attack: number;
decayTau: number;
hold: number;
}
@@ -0,0 +1,15 @@
import { BellTone } from './bell';
// Westminster Quarters: the classic clock-tower four-note phrase
// (E5 - D5 - C5 - G4), played slow legato so each note rings into the next.
// The final G4 gets a longer tail to resolve the phrase.
export class WestminsterTone extends BellTone {
protected _play(): void {
const t0 = this._currentTime;
this._strike(659.25, t0 + 0.0); // E5
this._strike(587.33, t0 + 0.55); // D5
this._strike(523.25, t0 + 1.1); // C5
this._strike(392.0, t0 + 1.65, { fundDecay: 0.9, humDecay: 1.4 }); // G4, longer tail
this._scheduleNext(5);
}
}
+7
View File
@@ -12,4 +12,11 @@ export interface CallSession {
// The view from before the call started: a clone with `queryResults` dropped.
// Used to undo the call when it ends.
previousView: View;
// Marks the session as inbound (auto-started, typically by a trigger) rather
// than the result of an explicit user gesture.
inbound: boolean;
// Whether the use has "answered" an inbound call.
answered: boolean;
}
@@ -70,6 +70,7 @@ export class CardElementManager {
this._api.getMicrophoneManager().initialize();
this._api.getPIPManager().initialize();
this._api.getKeyboardStateManager().initialize();
this._api.getCallManager().initialize();
// These initializers are called when the config is updated, but on initial
// creation of the card hass is not yet available when the config is first
@@ -188,6 +189,8 @@ export class CardElementManager {
// correctly and triggers that changed while detached are picked up.
// Reset trigger state first to stop stale timers and clear condition state.
this._api.getTriggersManager().reset();
this._api.getCallManager().uninitialize();
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
this._api
.getInitializationManager()
+31 -18
View File
@@ -181,28 +181,26 @@ export class TriggersManager {
private async _triggerAction(ev: CameraEvent): Promise<void> {
const config = this._api.getConfigManager().getConfig();
const triggerAction = config?.view?.triggers.actions.trigger;
const triggersConfig = config?.view?.triggers;
const triggerAction = triggersConfig?.actions.trigger;
const defaultView = config?.view?.default;
// Early exit guard: If this is a high-fidelity event where we are certain
// about new media, don't take action unless it's to change to live (Frigate
// engine may pump out events where there's no new media to show). Other
// trigger actions (e.g. media, update) do not make sense without having
// some new media.
if (
// Skip the trigger action for a high-fidelity "no new media" event when
// the configured action would change to a non-live view (Frigate may pump
// out such events). `live`, `call`, and default-with-live remain valid
// since they don't depend on media being available.
const skipViewAction =
ev.fidelity === 'high' &&
!ev.snapshot &&
!ev.clip &&
!ev.review &&
!(
triggerAction === 'call' ||
triggerAction === 'live' ||
(triggerAction === 'default' && defaultView === 'live')
)
) {
return;
}
);
if (this._hasAllowableInteractionStateForAction()) {
if (this._hasAllowableInteractionStateForAction() && !skipViewAction) {
if (triggerAction === 'update') {
await this._api.getViewManager().setViewByParametersWithNewQuery({
queryExecutorOptions: { useCache: false },
@@ -220,6 +218,10 @@ export class TriggersManager {
camera: ev.cameraID,
},
});
} else if (triggerAction === 'call') {
// Auto-call the triggered camera. `start()` itself handles the
// navigation to live -- it is idempotent if the view already matches.
await this._api.getCallManager().start({ cameraID: ev.cameraID, inbound: true });
} else if (ev.fidelity === 'high' && triggerAction === 'media') {
// Choose the most appropriate media view based on what's available.
// Priority: review > clip > snapshot
@@ -229,10 +231,10 @@ export class TriggersManager {
? 'clip'
: ev.snapshot
? 'snapshot'
: /* istanbul ignore next: unreachable due to early exit guard above -- @preserve */
: /* istanbul ignore next: unreachable due to `skipViewAction` above -- @preserve */
null;
/* istanbul ignore next: unreachable due to early exit guard above -- @preserve */
/* istanbul ignore next: unreachable due to `skipViewAction` above -- @preserve */
if (view) {
await this._api.getViewManager().setViewByParametersWithNewQuery({
params: {
@@ -255,7 +257,7 @@ export class TriggersManager {
});
}
private async _executeUntriggerAction(): Promise<boolean> {
private async _executeUntriggerAction(cameraID: string): Promise<boolean> {
const action = this._api.getConfigManager().getConfig()?.view?.triggers
.actions.untrigger;
@@ -263,8 +265,19 @@ export class TriggersManager {
return true;
}
if (this._hasAllowableInteractionStateForAction()) {
await this._api.getViewManager().setViewDefaultWithNewQuery();
if (!this._hasAllowableInteractionStateForAction()) {
return true;
}
switch (action) {
case 'default':
await this._api.getViewManager().setViewDefaultWithNewQuery();
break;
case 'call':
// Triggers only end a call if the call is owned by this cameraID, if it
// was an inbound call and was not yet answered.
this._api.getCallManager().endIf({ cameraID, inbound: true, answered: false });
break;
}
return true;
}
@@ -273,7 +286,7 @@ export class TriggersManager {
this._deleteUntriggerDelayTimer(cameraID);
this._deleteForceUntriggerTimer(cameraID);
await this._executeUntriggerAction();
await this._executeUntriggerAction(cameraID);
this._deleteStateIfIdle(cameraID);
this._setConditionStateIfNecessary();
+3
View File
@@ -80,6 +80,7 @@ export interface CardAutomationsAPI {
export interface CardCallAPI {
getCameraManager(): CameraManager;
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
getMicrophoneManager(): MicrophoneManager;
getNotificationManager(): NotificationManager;
getViewManager(): ViewManager;
@@ -151,6 +152,7 @@ export interface CardDownloadAPI {
export interface CardElementAPI {
getActionsManager(): ActionsManager;
getCallManager(): CallManager;
getCameraManager(): CameraManager;
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
@@ -312,6 +314,7 @@ export interface CardStyleAPI {
}
export interface CardTriggersAPI {
getCallManager(): CallManager;
getCameraManager(): CameraManager;
getConditionStateManager(): ConditionStateManager;
getCardElementManager(): CardElementManager;