feat: Add support for inbound "calls" from triggers (#2500)
This commit is contained in:
committed by
dermotduffy
parent
f4c686c298
commit
15e335a647
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user