fix: Hold the 2-way audio backchannel open for the shortest possible time (#2697)

The card claimed a camera's ONVIF audio backchannel in two places that
had
nothing to do with a call: the capability probe at camera init
(`&microphone`),
and a pre-armed `sendonly` audio transceiver on every live WebRTC offer.
Merely
looking at a dashboard occupied the camera's speaker line. Outbound
audio now
travels on its own audio-only WebRTC connection, opened when a call is
answered
and closed when it ends.

- The backchannel is claimed only for the duration of a call. Idle
viewing
  claims nothing.
- Two-way audio now works in `mse`, `mp4` and `mjpeg` modes (note: the
outbound
  audio still traverses WebRTC).
- No renegotiation and no video blink at call start or end.
- A call that cannot carry audio now reports it and ends, instead of
showing a
  live microphone that goes nowhere.
- `live.microphone.always_connected` is now purely about the browser
microphone
  permission prompt.
- Call setup measured at 66ms (LAN) and ~260ms (cellular) for ICE and
DTLS, plus
  ~300ms for `go2rtc` to open an RTSP backchannel.

Verified against a live Frigate + `go2rtc` instance, and by unit tests
at 100%
coverage.

 - Closes #2691
 - Closes #2039
 - Closes #2178

Ref #2299 -- the probe no longer opens a backchannel, but it still runs
per
camera on every load and reconnect, and still dials the camera on the
direct-`go2rtc` path. Caching remains to be done.

Ref AlexxIT/go2rtc#1860 -- once a call has opened a backchannel,
`go2rtc` keeps
that media set up on the camera's RTSP session for the life of the
producer.

Diagnoses #2678
This commit is contained in:
Dermot Duffy
2026-08-21 20:13:58 -07:00
committed by GitHub
parent 2d7c86c93c
commit 11cc543406
65 changed files with 2251 additions and 1045 deletions
+1 -4
View File
@@ -10,6 +10,7 @@ import {
resolveProxyConfig,
type EnabledProxyConfig,
} from '../config/schema/common/proxy';
import { getGo2RTCMetadataEndpoint, getGo2RTCStreamEndpoint } from '../go2rtc/endpoint';
import { computeDomain } from '../ha/compute-domain';
import { matchesEventContext, matchesEventData } from '../ha/event-match';
import { getTriggerEventType } from '../ha/get-trigger-event-type';
@@ -32,10 +33,6 @@ import type {
CameraProxyConfig,
} from './types';
import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
import {
getGo2RTCMetadataEndpoint,
getGo2RTCStreamEndpoint,
} from './utils/go2rtc/endpoint';
import { getConfiguredPTZAction } from './utils/ptz';
interface CapabilityOptions {
+4 -4
View File
@@ -3,6 +3,10 @@ import { format } from 'date-fns';
import type { ActionsExecutor } from '../../card-controller/actions/types';
import type { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
import type { CameraConfig } from '../../config/schema/cameras';
import {
getGo2RTCMetadataEndpoint,
getGo2RTCStreamEndpoint,
} from '../../go2rtc/endpoint';
import type { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
import type { HomeAssistant } from '../../ha/types';
import {
@@ -16,10 +20,6 @@ import { Camera, type CameraInitializationOptions } from '../camera';
import { CameraNoEntityError } from '../error';
import type { CameraEndpoints, CameraEndpointsContext } from '../types';
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
import {
getGo2RTCMetadataEndpoint,
getGo2RTCStreamEndpoint,
} from '../utils/go2rtc/endpoint';
import { getPTZCapabilitiesFromCameraConfig, mergePTZCapabilities } from '../utils/ptz';
import { getPTZInfo } from './requests';
import {
+87 -13
View File
@@ -1,3 +1,8 @@
import { createBackchannel } from '../../components-lib/live/backchannel/factory';
import {
BackchannelError,
type Backchannel,
} from '../../components-lib/live/backchannel/types';
import { createNotificationFromText } from '../../components-lib/notification/factory';
import type { ConditionStateChange } from '../../condition-trigger/conditions/types';
import { localize } from '../../localize/localize';
@@ -16,6 +21,8 @@ export class CallManager {
private _ringtone = new Ringtone();
private _unansweredTimer = new Timer();
private _backchannel: Backchannel | null = null;
// Identifies the current init/uninit cycle so an in-flight `start()` or
// `answer()` resuming from its microphone-connect await can detect that its
// CallManager was torn down -- or torn down and re-initialized -- while it
@@ -200,7 +207,8 @@ export class CallManager {
if (call.inbound && !call.answered && timeoutSeconds > 0) {
this._unansweredTimer.start(timeoutSeconds, () => this.end());
}
return true;
return call.answered ? await this._openBackchannel(call, inbound) : true;
}
// Ends the call and returns to the pre-call view. Returns true iff a call was
@@ -242,10 +250,12 @@ export class CallManager {
// Replace (don't mutate) so Lit identity checks downstream pick up the
// change. The `update()` below forces card.ts to re-render and re-read
// `getCall()`, propagating the new session to the carousel.
this._call = { ...call, answered: true };
const answeredCall = { ...call, answered: true };
this._call = answeredCall;
this._api.getConditionStateManager().setState({ call: 'answered' });
this._api.getCardElementManager().update();
return true;
return await this._openBackchannel(answeredCall, false);
}
// Ends the active call iff every supplied predicate matches the session.
@@ -270,20 +280,81 @@ export class CallManager {
return this.end();
}
// The microphone could not be used for the call, so it is connected but the
// user cannot be heard. `description` is what the reporting layer knows about
// the failure, when it knows anything.
public reportCallMicrophoneError(targetID: string, description?: string): void {
const call = this._call;
// Opens the backchannel for an answered call. Failing to open it ends
// the call, so the user is never left with call controls when they cannot be
// heard. Returns true iff the call is still running.
private async _openBackchannel(call: CallSession, inbound: boolean): Promise<boolean> {
const targetID = call.callCameraID ?? call.cameraID;
const hass = this._api.getHASSManager().getHASS();
const camera = this._api.getCameraManager().getStore().getCamera(targetID);
const stream = this._api.getMicrophoneManager().getStream();
// A report that no longer matches the call in progress describes an attempt
// the user has already moved past, e.g. the call ended before the provider
// finished reporting.
if (!call || !call.answered || call.cameraID !== targetID) {
const backchannel =
hass && camera
? createBackchannel(hass, camera, (error) =>
this._reportBackchannelLoss(call, error),
)
: null;
if (!backchannel || !stream) {
this._notifyError('error.call_no_two_way_audio', { inbound });
this._end(true);
return false;
}
this._backchannel = backchannel;
try {
await backchannel.start(stream);
} catch (error: unknown) {
if (this._call !== call) {
return false;
}
this._closeBackchannel();
this._notifyBackchannelError(error, inbound);
this._end(true);
return false;
}
if (this._call !== call) {
backchannel.stop();
return false;
}
return true;
}
private _closeBackchannel(): void {
this._backchannel?.stop();
this._backchannel = null;
}
private _reportBackchannelLoss(call: CallSession, error: BackchannelError): void {
if (this._call !== call) {
return;
}
this._notifyBackchannelError(error, call.inbound);
}
private _notifyBackchannelError(error: unknown, inbound: boolean): void {
const reason = error instanceof BackchannelError ? error.reason : 'failed';
// Abandonment means this manager closed the backchannel itself, because the
// call ended or was replaced. Nothing failed, so there is nothing to report.
if (reason === 'abandoned') {
return;
}
this._notifyError('error.call_microphone_failed', { context: description });
const messageKey =
reason === 'no_two_way_audio'
? 'error.call_no_two_way_audio'
: reason === 'no_microphone'
? 'error.call_microphone_failed'
: 'error.call_camera_unreachable';
this._notifyError(messageKey, {
inbound,
...(error instanceof BackchannelError &&
error.description && { context: error.description }),
});
}
// Tears down everything `initialize()` set up: stops any in-flight ringtone
@@ -296,6 +367,7 @@ export class CallManager {
this._initGeneration.invalidate();
this._ringtone.stop();
this._unansweredTimer.stop();
this._closeBackchannel();
this._api.getMicrophoneManager().setTransmissionActive(false);
if (this._call) {
this._call = null;
@@ -324,6 +396,8 @@ export class CallManager {
this._ringtone.stop();
this._unansweredTimer.stop();
this._closeBackchannel();
// 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.
+25
View File
@@ -101,8 +101,10 @@ export class MicrophoneManager {
// A connect over an existing stream must not leak the tracks of the
// stream it replaces.
this._removeEndedListeners(this._stream);
this._stopTracks(this._stream);
this._stream = stream;
this._addEndedListeners(stream);
this._forbidden = false;
this._reconcile();
this._setState();
@@ -171,8 +173,31 @@ export class MicrophoneManager {
stream?.getTracks().forEach((track) => track.stop());
}
// A device that disappears -- unplugged, or its permission revoked -- ends
// its tracks. Nothing can revive them, so the stream is dropped and the new
// state published, rather than leaving the card reporting a connected
// microphone that captures nothing. `stop()` does not fire this event, so
// releasing the stream cannot re-enter.
private _handleTrackEnded = (): void => {
this._releaseStream();
this._setState();
};
private _addEndedListeners(stream: MediaStream): void {
stream
.getTracks()
.forEach((track) => track.addEventListener('ended', this._handleTrackEnded));
}
private _removeEndedListeners(stream: MediaStream | null): void {
stream
?.getTracks()
.forEach((track) => track.removeEventListener('ended', this._handleTrackEnded));
}
private _releaseStream(): void {
this._connectGeneration.invalidate();
this._removeEndedListeners(this._stream);
this._stopTracks(this._stream);
this._stream = null;
}
+1
View File
@@ -85,6 +85,7 @@ export interface CardCallAPI {
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
getHASSManager(): HASSManager;
getMicrophoneManager(): MicrophoneManager;
getNotificationManager(): NotificationManager;
getViewManager(): ViewManager;
-7
View File
@@ -22,7 +22,6 @@ import type {
IssueTriggerEventData,
} from './card-controller/issues/types.js';
import { resolveAutoHideState, type AutoHideState } from './components-lib/auto-hide.js';
import type { MicrophoneError } from './components-lib/live/utils/dispatch-microphone-error.js';
import { MenuButtonController } from './components-lib/menu-button-controller';
import './components/effects/effects';
@@ -442,12 +441,6 @@ export class AdvancedCameraCard extends LitElement {
detail: { key, ...context },
}: CustomEvent<IssueResolveEventData>) =>
this._controller.getIssueManager().resolve(key, context)}
@advanced-camera-card:microphone:error=${({
detail,
}: CustomEvent<MicrophoneError>) =>
this._controller
.getCallManager()
.reportCallMicrophoneError(detail.targetID, detail.description)}
@advanced-camera-card:media:loaded=${(
ev: CustomEvent<MediaLoadedInfoEventDetail>,
) => this._controller.getMediaLoadedInfoManager().handleLoadEvent(ev)}
@@ -0,0 +1,25 @@
import type { Camera } from '../../../camera-manager/camera';
import type { HomeAssistant } from '../../../ha/types';
import {
getResolvedLiveProvider,
isGo2RTCLiveProvider,
} from '../../../utils/live-provider';
import { Go2RTCBackchannel } from './go2rtc';
import type { Backchannel, BackchannelErrorCallback } from './types';
export const createBackchannel = (
hass: HomeAssistant,
camera: Camera,
errorCallback?: BackchannelErrorCallback,
): Backchannel | null => {
if (!isGo2RTCLiveProvider(getResolvedLiveProvider(camera.getConfig()))) {
return null;
}
const endpoint = camera.getEndpoints()?.go2rtc;
return endpoint
? new Go2RTCBackchannel(hass, endpoint, camera.getLiveProxyConfig(), {
errorCallback,
})
: null;
};
@@ -0,0 +1,337 @@
import type { EnabledProxyConfig } from '../../../config/schema/common/proxy';
import { isServerErrorForMode, type Go2RTCMessage } from '../../../go2rtc/messages';
import {
createBrowserPeerConnection,
GO2RTC_PEER_CONNECTION_CONFIG,
type PeerConnectionFactory,
} from '../../../go2rtc/peer-connection';
import { SignalingChannel, type WebSocketFactory } from '../../../go2rtc/signaling';
import { resolveEndpointURL } from '../../../ha/resolve-endpoint';
import type { HomeAssistant } from '../../../ha/types';
import type { Endpoint, UnsubscribeCallback } from '../../../types';
import { getErrorDescription } from '../../../utils/basic';
import { Generation } from '../../../utils/concurrency/generation';
import { Timer } from '../../../utils/timer';
import { convertToWebSocketURL } from '../../../utils/websocket-url';
import {
BackchannelError,
type Backchannel,
type BackchannelErrorCallback,
} from './types';
const BACKCHANNEL_CONNECT_TIMEOUT_SECONDS = 10;
const toBackchannelError = (error: unknown): BackchannelError =>
error instanceof BackchannelError
? error
: new BackchannelError('failed', getErrorDescription(error) ?? undefined);
interface PendingStart {
resolve: () => void;
reject: (error: BackchannelError) => void;
}
export interface Go2RTCBackchannelOptions {
createWebSocket?: WebSocketFactory;
createPeerConnection?: PeerConnectionFactory;
errorCallback?: BackchannelErrorCallback;
}
// Carries microphone audio to a camera over its own WebRTC connection to
// go2rtc, separate from whatever is carrying video. go2rtc claims the camera's
// audio backchannel when this connection's offer arrives and releases it when
// the connection closes, so the camera is occupied only for the duration of a
// call.
// See: https://github.com/dermotduffy/advanced-camera-card/discussions/2678
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2691
export class Go2RTCBackchannel implements Backchannel {
private _hass: HomeAssistant;
private _endpoint: Endpoint;
private _proxyConfig: EnabledProxyConfig | null;
private _options: Go2RTCBackchannelOptions | null;
private _pc: RTCPeerConnection | null = null;
private _channel: SignalingChannel | null = null;
private _transceiver: RTCRtpTransceiver | null = null;
private _unsubscribeCallbacks: UnsubscribeCallback[] = [];
private _connectTimer = new Timer();
private _generation = new Generation();
private _outboundTrack: MediaStreamTrack | null = null;
// `start()` cannot determine its own outcome: the camera is only known to be
// reachable once the peer connection reports `connected`, and go2rtc may
// refuse in a message that arrives even later. Whichever handler learns the
// outcome completes `start()` through these.
private _pendingStart: PendingStart | null = null;
constructor(
hass: HomeAssistant,
endpoint: Endpoint,
proxyConfig?: EnabledProxyConfig,
options?: Go2RTCBackchannelOptions,
) {
this._hass = hass;
this._endpoint = endpoint;
this._proxyConfig = proxyConfig ?? null;
this._options = options ?? null;
}
public start(stream: MediaStream): Promise<void> {
this.stop();
const generation = this._generation.next();
// Both exist before the first await: a timeout cannot end a wait unless the
// promise it rejects has already been created.
return new Promise<void>((resolve, reject) => {
this._pendingStart = { resolve, reject };
this._connectTimer.start(BACKCHANNEL_CONNECT_TIMEOUT_SECONDS, () =>
this._failStart(generation, new BackchannelError('failed')),
);
this._start(stream, generation).catch((error: unknown) =>
this._failStart(generation, toBackchannelError(error)),
);
});
}
private async _start(stream: MediaStream, generation: number): Promise<void> {
// A microphone stream carries exactly one audio track.
const track = stream.getAudioTracks()[0] ?? null;
if (!track || track.readyState === 'ended') {
throw new BackchannelError('no_microphone');
}
const resolvedURL = await resolveEndpointURL(this._hass, this._endpoint, {
proxyConfig: this._proxyConfig,
proxyEndpointOptions: { websocket: true },
});
if (!this._generation.isCurrent(generation)) {
throw new BackchannelError('abandoned');
}
if (!resolvedURL.success) {
throw new BackchannelError('failed', resolvedURL.error);
}
const pc = (this._options?.createPeerConnection ?? createBrowserPeerConnection)(
GO2RTC_PEER_CONNECTION_CONFIG,
);
this._pc = pc;
// This connection only sends audio.
this._transceiver = pc.addTransceiver(track, { direction: 'sendonly' });
this._watchTrack(track);
const channel = new SignalingChannel(
convertToWebSocketURL(resolvedURL.url),
{
openCallback: () => this._negotiate(pc, channel, generation),
disconnectCallback: () => this._handleDisconnect(generation),
},
{ createWebSocket: this._options?.createWebSocket },
);
this._channel = channel;
this._unsubscribeCallbacks.push(
channel.subscribeToMessages((message) =>
this._handleMessage(pc, message, generation),
),
);
pc.addEventListener('icecandidate', (ev) => {
if (!this._generation.isCurrent(generation)) {
return;
}
// An empty value signals end-of-candidates.
channel.send({
type: 'webrtc/candidate',
value: ev.candidate ? ev.candidate.candidate : '',
});
});
pc.addEventListener('connectionstatechange', () =>
this._handleConnectionStateChange(pc, generation),
);
channel.connect();
}
public async setStream(stream: MediaStream): Promise<void> {
const transceiver = this._transceiver;
// Nothing to swap onto: the call this belonged to has already ended.
if (!transceiver) {
return;
}
// A microphone stream carries exactly one audio track. Detaching the sender
// instead would leave the user believing they can be heard.
const track = stream.getAudioTracks()[0] ?? null;
if (!track || track.readyState === 'ended') {
throw new BackchannelError('no_microphone');
}
await transceiver.sender.replaceTrack(track);
this._watchTrack(track);
}
public stop(): void {
this._generation.invalidate();
this._connectTimer.stop();
const pendingStart = this._pendingStart;
this._pendingStart = null;
this._unsubscribeCallbacks.forEach((unsubscribe) => unsubscribe());
this._unsubscribeCallbacks = [];
this._channel?.close();
this._channel = null;
this._unwatchTrack();
// Closing the peer connection is what makes go2rtc release the camera's
// backchannel. The outbound track keeps running: MicrophoneManager owns the
// microphone and shares it across cameras.
// See https://github.com/dermotduffy/advanced-camera-card/issues/1810
this._pc?.close();
this._pc = null;
this._transceiver = null;
pendingStart?.reject(new BackchannelError('abandoned'));
}
private _handleTrackEnded = (): void =>
this._reportLost(this._generation.current(), new BackchannelError('no_microphone'));
private _watchTrack(track: MediaStreamTrack): void {
this._unwatchTrack();
track.addEventListener('ended', this._handleTrackEnded);
this._outboundTrack = track;
}
private _unwatchTrack(): void {
this._outboundTrack?.removeEventListener('ended', this._handleTrackEnded);
this._outboundTrack = null;
}
private _reportLost(generation: number, error: BackchannelError): void {
if (this._pendingStart) {
this._failStart(generation, error);
return;
}
this.stop();
this._options?.errorCallback?.(error);
}
private async _negotiate(
pc: RTCPeerConnection,
channel: SignalingChannel,
generation: number,
): Promise<void> {
try {
const offer = await pc.createOffer();
if (!this._generation.isCurrent(generation)) {
return;
}
await pc.setLocalDescription(offer);
if (!this._generation.isCurrent(generation)) {
return;
}
channel.send({ type: 'webrtc/offer', value: offer.sdp ?? '' });
} catch (error: unknown) {
this._failStart(
generation,
new BackchannelError('failed', getErrorDescription(error) ?? undefined),
);
}
}
private _handleMessage(
pc: RTCPeerConnection,
message: Go2RTCMessage,
generation: number,
): void {
// go2rtc refuses a stream it cannot send audio to with an error frame
// rather than an answer.
if (isServerErrorForMode(message, 'webrtc')) {
this._failStart(
generation,
new BackchannelError('no_two_way_audio', message.value),
);
return;
}
if (typeof message.value !== 'string') {
return;
}
switch (message.type) {
case 'webrtc/answer':
pc.setRemoteDescription({ type: 'answer', sdp: message.value }).catch(
(error: unknown) =>
this._failStart(
generation,
new BackchannelError('failed', getErrorDescription(error) ?? undefined),
),
);
break;
case 'webrtc/candidate':
if (message.value) {
// The server sends no sdpMid; max-bundle puts every track on m-line 0.
pc.addIceCandidate({ candidate: message.value, sdpMid: '0' }).catch(() => {});
}
break;
}
}
private _handleDisconnect(generation: number): void {
// This socket is closed deliberately once the peer connection is
// established, so a close arriving here is always premature: the offer and
// answer travel over it and cannot complete without it.
this._failStart(generation, new BackchannelError('failed'));
}
private _handleConnectionStateChange(pc: RTCPeerConnection, generation: number): void {
if (!this._generation.isCurrent(generation)) {
return;
}
if (pc.connectionState === 'connected') {
const direction = this._transceiver?.currentDirection;
if (direction !== 'sendonly' && direction !== 'sendrecv') {
this._failStart(generation, new BackchannelError('no_two_way_audio'));
return;
}
this._connectTimer.stop();
// Signaling is finished, and the camera's backchannel belongs to the peer
// connection rather than to this socket. Leaving it open would let a
// proxy drop it as idle much later, which `_handleDisconnect` reports as
// a failure.
this._channel?.close();
this._channel = null;
this._pendingStart?.resolve();
this._pendingStart = null;
} else if (pc.connectionState === 'failed') {
this._reportLost(generation, new BackchannelError('failed'));
}
}
// Aborts an in-progress `start()`. Every failure before `start()` resolves
// arrives here; afterwards there is no request left to fail.
private _failStart(generation: number, error: BackchannelError): void {
if (!this._generation.isCurrent(generation)) {
return;
}
// Taken before the teardown so this reason is reported rather than the
// abandonment `stop()` would otherwise report.
const pendingStart = this._pendingStart;
this._pendingStart = null;
this.stop();
pendingStart?.reject(error);
}
}
@@ -0,0 +1,33 @@
export type BackchannelFailureReason =
| 'no_two_way_audio'
| 'no_microphone'
| 'failed'
| 'abandoned';
export class BackchannelError extends Error {
public readonly reason: BackchannelFailureReason;
public readonly description: string | null;
constructor(reason: BackchannelFailureReason, description?: string) {
super(description ?? reason);
this.reason = reason;
this.description = description ?? null;
}
}
// Carries audio from the browser microphone to a camera, held for the
// duration of a call. Implementations are per live provider.
export interface Backchannel {
// Opens the backchannel carrying the given microphone stream. Resolves once the
// camera can actually be spoken to.
start(stream: MediaStream): Promise<void>;
// Swaps the microphone stream being carried, leaving the backchannel open.
setStream(stream: MediaStream): Promise<void>;
// Closes the backchannel, releasing the camera (the microphone itself belongs to
// MicrophoneManager and is untouched).
stop(): void;
}
export type BackchannelErrorCallback = (error: BackchannelError) => void;
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 924 KiB

After

Width:  |  Height:  |  Size: 924 KiB

@@ -2,6 +2,8 @@ import { isEqual } from 'lodash-es';
import { GO2RTC_MODES, type Go2RTCMode } from '../../../../config/schema/cameras';
import type { CardWideConfig } from '../../../../config/schema/types';
import type { PeerConnectionFactory } from '../../../../go2rtc/peer-connection';
import { SignalingChannel, type WebSocketFactory } from '../../../../go2rtc/signaling';
import type {
MediaPlayerController,
UntargetedMediaLoadedInfo,
@@ -19,9 +21,7 @@ import { createMediaLoadedInfo } from '../../../../utils/media-info';
import { RetryTimer } from '../../../../utils/retry-timer';
import { convertToWebSocketURL } from '../../../../utils/websocket-url';
import type { MediaSourceFactory } from './adapters/media-source';
import type { PeerConnectionFactory } from './adapters/peer-connection';
import { OffscreenVideo, type VideoElementFactory } from './offscreen-video';
import { SignalingChannel, type WebSocketFactory } from './signaling';
import {
createBinarySource,
createWebRTCSource,
@@ -97,11 +97,6 @@ interface Go2RTCSessionCallbacks {
// The reason is the most recent source failure, or null when there is none
// (e.g. the socket dropped with no source having reported a cause).
streamErrorCallback: (reason: StreamSourceFailureReason | null) => void;
// The outbound microphone could not be used, so the camera cannot be talked
// to. The inbound video is unaffected. `error` is what the source knows about
// the failure, when it knows anything.
microphoneErrorCallback: (error?: string) => void;
}
// Injectable platform and factory seams for tests. Every field defaults to
@@ -141,7 +136,6 @@ export class Go2RTCSessionController {
private _url: string | null = null;
private _surfaces: SessionSurfaces | null = null;
private _modes: readonly Go2RTCMode[] = GO2RTC_MODES;
private _microphoneStream: MediaStream | null = null;
// Binary lane: the active source paired with the surface it renders on (mse
// -> video, MP4/MJPEG -> image). Kept as one unit because the factory returns
@@ -249,11 +243,6 @@ export class Go2RTCSessionController {
this._surfaces = null;
}
public setMicrophoneStream(stream: MediaStream | null): void {
this._microphoneStream = stream;
this._webRTCSource?.setMicrophoneStream(stream).catch(() => {});
}
// ===========================================================================
// Session teardown.
// ===========================================================================
@@ -462,8 +451,6 @@ export class Go2RTCSessionController {
};
source = (this._options?.createWebRTCSource ?? createWebRTCSource)(sourceContext, {
microphoneStream: this._microphoneStream,
microphoneErrorCallback: (error) => this._callbacks.microphoneErrorCallback(error),
createPeerConnection: this._options?.createPeerConnection,
createMediaStream: this._options?.createMediaStream,
});
@@ -1,6 +1,6 @@
import type { Go2RTCMode } from '../../../../../config/schema/cameras';
import type { PeerConnectionFactory } from '../../../../../go2rtc/peer-connection';
import type { MediaSourceFactory } from '../adapters/media-source';
import type { PeerConnectionFactory } from '../adapters/peer-connection';
import type {
ImageStreamTarget,
StreamSource,
@@ -88,8 +88,6 @@ export const createBinarySource: BinarySourceFactory = (
export interface CreateWebRTCSourceOptions {
createPeerConnection?: PeerConnectionFactory;
createMediaStream?: MediaStreamFactory;
microphoneStream?: MediaStream | null;
microphoneErrorCallback?: (error?: string) => void;
}
export type WebRTCSourceFactory = (
@@ -1,4 +1,8 @@
import type { Go2RTCMode } from '../../../../../config/schema/cameras';
import {
isServerErrorForMode,
type Go2RTCMessage,
} from '../../../../../go2rtc/messages';
import type {
MediaLoadedCapabilities,
MediaTechnology,
@@ -6,13 +10,11 @@ import type {
} from '../../../../../types';
import { Timer } from '../../../../../utils/timer';
import type {
Go2RTCMessage,
ImageStreamTarget,
StreamProfile,
StreamSource,
StreamSourceContext,
} from '../types';
import { isServerErrorForMode } from '../utils/messages';
// Fail if no frame arrives within this window. The channel is open and the mode
// was requested, but the server may send neither a frame nor an error, so
@@ -1,4 +1,4 @@
import type { Go2RTCMessage } from '../types';
import type { Go2RTCMessage } from '../../../../../go2rtc/messages';
import { ImageFrameStreamSource } from './image-frame';
// Each binary frame is a complete JPEG, shown directly as an image frame.
@@ -1,5 +1,6 @@
import type { Go2RTCMessage } from '../../../../../go2rtc/messages';
import { OffscreenVideo, type VideoElementFactory } from '../offscreen-video';
import type { Go2RTCMessage, ImageStreamTarget, StreamSourceContext } from '../types';
import type { ImageStreamTarget, StreamSourceContext } from '../types';
import { arrayBufferToBase64 } from '../utils/base64';
import {
convertToCodecString,
@@ -1,3 +1,7 @@
import {
isServerErrorForMode,
type Go2RTCMessage,
} from '../../../../../go2rtc/messages';
import type {
MediaLoadedCapabilities,
MediaTechnology,
@@ -11,7 +15,6 @@ import {
type MediaSourceInterface,
} from '../adapters/media-source';
import type {
Go2RTCMessage,
StreamProfile,
StreamSource,
StreamSourceContext,
@@ -25,7 +28,6 @@ import {
} from '../utils/codecs';
import { LiveEdgeTracker } from '../utils/live-edge-tracker';
import type { LiveEdgeAction } from '../utils/live-edge-tracker/types';
import { isServerErrorForMode } from '../utils/messages';
import { isWebKitUserAgent } from '../utils/user-agent';
// ===========================================================================
@@ -165,7 +167,6 @@ export class MSEStreamSource implements StreamSource {
return {
supportsPause: true,
hasAudio: hasAudio(this._context.target.video, { mseCodecs: this._codecs }),
has2WayAudio: false,
};
}
@@ -1,24 +1,25 @@
import {
isServerErrorForMode,
type Go2RTCMessage,
} from '../../../../../go2rtc/messages';
import {
createBrowserPeerConnection,
GO2RTC_PEER_CONNECTION_CONFIG,
type PeerConnectionFactory,
} from '../../../../../go2rtc/peer-connection';
import type {
MediaLoadedCapabilities,
MediaTechnology,
UnsubscribeCallback,
} from '../../../../../types';
import { has2WayAudio, hasAudio } from '../../../../../utils/audio';
import { isRecord } from '../../../../../utils/basic';
import { hasAudio } from '../../../../../utils/audio';
import { Timer } from '../../../../../utils/timer';
import {
createBrowserPeerConnection,
GO2RTC_PEER_CONNECTION_CONFIG,
type PeerConnectionFactory,
} from '../adapters/peer-connection';
import type {
Go2RTCMessage,
StreamProfile,
StreamSource,
StreamSourceContext,
VideoStreamTarget,
} from '../types';
import { isServerErrorForMode } from '../utils/messages';
import { sdpHasH265 } from '../utils/webrtc-sdp';
// ===========================================================================
@@ -39,32 +40,9 @@ const WEBRTC_CONNECT_TIMEOUT_SECONDS = 5;
export type MediaStreamFactory = (tracks: MediaStreamTrack[]) => MediaStream;
// What a thrown value has to say for itself, preferring the browser's sentence
// ("The peer connection is closed") over the bare type name
// ("InvalidStateError"), which means nothing to the person reading it.
//
// DOMException may not inherit from Error, and catch blocks may be handed
// anything, so extract details structurally rather than using `instanceof
// Error`.
const getErrorDescription = (error: unknown): string | null => {
if (!isRecord(error)) {
return null;
}
const message = typeof error.message === 'string' ? error.message : '';
const name = typeof error.name === 'string' ? error.name : '';
return message || name || null;
};
interface WebRTCStreamSourceOptions {
createPeerConnection?: PeerConnectionFactory;
createMediaStream?: MediaStreamFactory;
microphoneStream?: MediaStream | null;
// The outbound microphone track could not be attached. Separate from the
// stream-source failure channel: a microphone that cannot attach says nothing
// about the inbound video which keeps playing. `error` is what the browser
// said went wrong, when it said anything.
microphoneErrorCallback?: (error?: string) => void;
}
export class WebRTCStreamSource implements StreamSource {
@@ -74,10 +52,6 @@ export class WebRTCStreamSource implements StreamSource {
private _createPeerConnection: PeerConnectionFactory;
private _createMediaStream: MediaStreamFactory;
private _microphoneStream: MediaStream | null;
private _microphoneErrorCallback: ((error?: string) => void) | null;
private _microphoneTransceiver: RTCRtpTransceiver | null = null;
private _connectTimer = new Timer();
@@ -97,23 +71,12 @@ export class WebRTCStreamSource implements StreamSource {
options?.createPeerConnection ?? createBrowserPeerConnection;
this._createMediaStream =
options?.createMediaStream ?? ((tracks) => new MediaStream(tracks));
this._microphoneStream = options?.microphoneStream ?? null;
this._microphoneErrorCallback = options?.microphoneErrorCallback ?? null;
}
public start(): void {
const pc = this._createPeerConnection(GO2RTC_PEER_CONNECTION_CONFIG);
this._pc = pc;
// Always pre-arm exactly one outbound audio slot so the microphone track
// can be attached later via `replaceTrack` with no renegotiation. The
// kind-only `addTransceiver('audio', ...)` form never calls getUserMedia,
// so it never raises permission prompt for users.
const microphoneTrack = this._microphoneStream?.getAudioTracks()[0] ?? null;
this._microphoneTransceiver = pc.addTransceiver(microphoneTrack ?? 'audio', {
direction: 'sendonly',
});
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
@@ -162,17 +125,10 @@ export class WebRTCStreamSource implements StreamSource {
this._context.target.video.removeEventListener('loadeddata', this._loadedHandler);
if (this._pc) {
// pc.close() does not stop the sender's tracks, so the outbound microphone
// track keeps running. That is deliberate: MicrophoneManager owns the mic
// (it is shared across cameras), so stopping it here would break it
// elsewhere -- do not add a track.stop() here. See
// https://github.com/dermotduffy/advanced-camera-card/issues/1810
this._pc.close();
this._pc = null;
}
// The transceiver belonged to the now-closed peer connection.
this._microphoneTransceiver = null;
this._context.target.video.srcObject = null;
this._stream = null;
}
@@ -189,7 +145,6 @@ export class WebRTCStreamSource implements StreamSource {
return {
supportsPause: true,
hasAudio: hasAudio(this._context.target.video, { pc: this._pc }),
has2WayAudio: has2WayAudio(this._pc),
};
}
@@ -208,45 +163,6 @@ export class WebRTCStreamSource implements StreamSource {
};
}
// Swap the outbound microphone track without renegotiating.
public async setMicrophoneStream(stream: MediaStream | null): Promise<void> {
if (this._microphoneStream === stream) {
return;
}
this._microphoneStream = stream;
const transceiver = this._microphoneTransceiver;
if (!transceiver) {
// No peer connection yet; the next `start()` reads the current stream and
// pre-arms the transceiver with it.
return;
}
// Whether the awaited microphone request is still the one in effect: a newer
// stream, or teardown, retires it, and reporting a retired outcome would
// describe something that is no longer being attempted.
const isCurrentRequest = (
transceiver: RTCRtpTransceiver,
stream: MediaStream | null,
): boolean =>
transceiver === this._microphoneTransceiver &&
this._microphoneStream === stream &&
this._pc !== null;
// A microphone stream carries a single audio track; null detaches the sender.
const desiredTrack = stream?.getAudioTracks()[0] ?? null;
try {
await transceiver.sender.replaceTrack(desiredTrack);
} catch (error) {
// Only a failed attach is reported. A failed detach leaves nothing for
// the user to act on: the track stops being transmitted when the peer
// connection closes.
if (desiredTrack && isCurrentRequest(transceiver, stream)) {
this._microphoneErrorCallback?.(getErrorDescription(error) ?? undefined);
}
}
}
private async _negotiate(pc: RTCPeerConnection): Promise<void> {
const offer = await pc.createOffer();
if (this._pc !== pc) {
@@ -1,30 +1,14 @@
import { z } from 'zod';
import type {
BinaryCallback,
Go2RTCMessage,
MessageCallback,
} from '../../../../go2rtc/messages';
import type {
MediaLoadedCapabilities,
MediaTechnology,
UnsubscribeCallback,
} from '../../../../types';
// ===========================================================================
// Control messages
// ===========================================================================
// go2rtc control messages are JSON text frames of this shape; media flows as
// separate binary frames.
export const go2RTCMessageSchema = z.object({
type: z.string(),
// Per-type payload (a codec list, an SDP, an ICE candidate, error text, ...):
// absent for some types (e.g. mjpeg) and not always a string, so it is typed
// `unknown` and each handler narrows it before use.
value: z.unknown().optional(),
});
export type Go2RTCMessage = z.infer<typeof go2RTCMessageSchema>;
export type MessageCallback = (message: Go2RTCMessage) => void;
export type BinaryCallback = (data: ArrayBuffer) => void;
// ===========================================================================
// Signaling channel
// ===========================================================================
@@ -1,9 +0,0 @@
import type { Go2RTCMessage } from '../types';
// The go2rtc server reports a mode failure as `{ type: 'error', value: '<mode>: ...' }`
// (e.g. `mse: stream not found`), so an error is for a given mode when its value
// starts with that mode's name.
export const isServerErrorForMode = (message: Go2RTCMessage, mode: string): boolean =>
message.type === 'error' &&
typeof message.value === 'string' &&
message.value.startsWith(mode);
@@ -1,27 +0,0 @@
import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event';
// What a provider knows about its own microphone related failure. Stream
// otherwise not impacted (contrast with `live:error`: which marks the whole
// stream not live).
export interface MicrophoneError {
// The base camera the provider is rendering. Carried because this event is
// handled once for the whole card, unlike `live:error` which is caught and
// stopped on the camera's own provider wrapper and so needs no camera named.
targetID: string;
// Free text naming the specific failure, when the provider has one.
description?: string;
}
declare global {
interface HTMLElementEventMap {
'advanced-camera-card:microphone:error': CustomEvent<MicrophoneError>;
}
}
export function dispatchMicrophoneErrorEvent(
element: EventTarget,
error: MicrophoneError,
): void {
fireAdvancedCameraCardEvent<MicrophoneError>(element, 'microphone:error', error);
}
+11 -72
View File
@@ -2,19 +2,16 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit';
import { isEqual } from 'lodash-es';
import type { EnabledProxyConfig } from '../config/schema/common/proxy.js';
import { homeAssistantGetSignedURLIfNecessary } from '../ha/sign-path.js';
import type { HomeAssistant } from '../ha/types.js';
import {
createProxiedEndpointIfNecessary,
type CreateProxiedEndpointOptions,
} from '../ha/web-proxy.js';
PROXY_URL_SIGN_EXPIRY_SECONDS,
resolveEndpointURL,
} from '../ha/resolve-endpoint.js';
import type { HomeAssistant } from '../ha/types.js';
import type { CreateProxiedEndpointOptions } from '../ha/web-proxy.js';
import { localize } from '../localize/localize.js';
import type { Endpoint } from '../types.js';
import { errorToConsole } from '../utils/basic.js';
import { Generation } from '../utils/concurrency/generation.js';
const PROXY_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
// Re-register and re-sign well before the signed URL expires.
const PROXY_CACHE_TTL_SECONDS = PROXY_URL_SIGN_EXPIRY_SECONDS / 2;
@@ -146,78 +143,20 @@ export class SignedURLController implements ReactiveController {
this._cachedAt = null;
const requestID = this._requestGeneration.next();
const resolvedEndpoint = await this._proxy(
const resolved = await resolveEndpointURL(
hass,
targetURL,
endpoint,
proxyConfig,
proxyEndpointOptions,
{ endpoint: targetURL, sign: endpoint.sign },
{ proxyConfig, proxyEndpointOptions },
);
if (!this._requestGeneration.isCurrent(requestID)) {
return;
}
if (!resolvedEndpoint) {
this._applyError('proxy');
if (!resolved.success) {
this._applyError(resolved.error);
return;
}
const signedURL = await this._sign(hass, resolvedEndpoint);
if (!this._requestGeneration.isCurrent(requestID)) {
return;
}
if (!signedURL) {
this._applyError('sign');
return;
}
this._applySuccess(signedURL);
}
/**
* Proxy the endpoint if proxying is enabled, otherwise return it as-is.
*/
private async _proxy(
hass: HomeAssistant,
targetURL: string,
endpoint: Endpoint,
proxyConfig: EnabledProxyConfig | null | undefined,
proxyEndpointOptions: CreateProxiedEndpointOptions | undefined,
): Promise<Endpoint | null> {
if (!proxyConfig?.enabled) {
return { endpoint: targetURL, sign: endpoint.sign };
}
try {
return await createProxiedEndpointIfNecessary(
hass,
{ endpoint: targetURL, sign: false },
proxyConfig,
{
ttl: PROXY_URL_SIGN_EXPIRY_SECONDS,
openLimit: 0,
...proxyEndpointOptions,
},
);
} catch (e: unknown) {
errorToConsole(e);
return null;
}
}
/**
* Sign the endpoint if it requires signing, otherwise return the URL as-is.
*/
private async _sign(hass: HomeAssistant, endpoint: Endpoint): Promise<string | null> {
try {
return await homeAssistantGetSignedURLIfNecessary(
hass,
endpoint,
PROXY_URL_SIGN_EXPIRY_SECONDS,
);
} catch (e: unknown) {
errorToConsole(e);
return null;
}
this._applySuccess(resolved.url);
}
private _applySuccess(url: string): void {
-22
View File
@@ -265,14 +265,11 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
const mediaEpoch = view?.context?.mediaEpoch?.[cameraID] ?? 0;
const isSelectedSlide = !!view?.camera && cameraID === view.camera;
const microphoneStream = this._getRelevantMicrophoneStream(cameraID, view);
return html`
<div class="embla__slide">
${keyed(
mediaEpoch,
html`<advanced-camera-card-live-provider
.microphoneStream=${microphoneStream}
.camera=${resolvedCamera}
.targetID=${cameraID}
.cameraTitle=${cameraMetadata?.title}
@@ -304,25 +301,6 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
return view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
}
// Return a microphone stream only for the camera the call runs on, only
// while the call has been answered, and only while that camera's engaged
// stream is still the call's audio source. The `answered` gate is a
// privacy guarantee: an inbound call that's still ringing must not
// transmit audio even if the mic happens to be un-muted (e.g. left open
// by `auto_unmute: ['selected']` or a prior call). The substream gate
// stops transmission if the substream has since changed.
private _getRelevantMicrophoneStream(
cameraID: string,
view?: View | null,
): MediaStream | null {
const isRelevant =
!!this.call?.answered &&
this.call.cameraID === cameraID &&
this._getSubstreamCameraID(cameraID, view) ===
(this.call.callCameraID ?? cameraID);
return isRelevant ? this.microphoneState?.stream ?? null : null;
}
private _toggleMute(): void {
const controller = this._mediaLoadedInfoSinkController.get()?.mediaPlayerController;
// Fire-and-forget; the `volumechange` event drives the re-render.
-7
View File
@@ -71,9 +71,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public microphoneStream?: MediaStream | null;
@property({ attribute: false })
public zoomSettings?: PartialZoomSettings | null;
@@ -430,8 +427,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
.camera=${this.camera}
.targetID=${this.targetID}
.cameraTitle=${this.cameraTitle}
.microphoneStream=${this.microphoneStream}
.microphoneConfig=${this.liveConfig.microphone}
?controls=${this._getEffectiveBuiltinControls()}
>
</advanced-camera-card-live-go2rtc>`
@@ -443,8 +438,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
.camera=${this.camera}
.targetID=${this.targetID}
.cameraTitle=${this.cameraTitle}
.microphoneStream=${this.microphoneStream}
.microphoneConfig=${this.liveConfig.microphone}
.cardWideConfig=${this.cardWideConfig}
?controls=${this._getEffectiveBuiltinControls()}
>
@@ -23,14 +23,12 @@ import {
import type { SurfaceKind } from '../../../../components-lib/live/providers/go2rtc-experimental/types.js';
import { mapStreamFailureReasonToIssueReason } from '../../../../components-lib/live/providers/go2rtc-experimental/utils/stream-failure-reason.js';
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
import { dispatchMicrophoneErrorEvent } from '../../../../components-lib/live/utils/dispatch-microphone-error.js';
import { MediaLoadedInfoSourceController } from '../../../../components-lib/media-loaded-info-source-controller.js';
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
import {
getSignedURLErrorText,
SignedURLController,
} from '../../../../components-lib/signed-url-controller.js';
import type { MicrophoneConfig } from '../../../../config/schema/live.js';
import type { CardWideConfig } from '../../../../config/schema/types.js';
import type { HomeAssistant } from '../../../../ha/types.js';
import { localize } from '../../../../localize/localize.js';
@@ -58,12 +56,6 @@ export class AdvancedCameraCardGo2RTCExperimental
@property({ attribute: false })
public targetID?: string;
@property({ attribute: false })
public microphoneStream?: MediaStream | null;
@property({ attribute: false })
public microphoneConfig?: MicrophoneConfig;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@@ -168,20 +160,8 @@ export class AdvancedCameraCardGo2RTCExperimental
this._streamError = mapStreamFailureReasonToIssueReason(reason);
dispatchLiveErrorEvent(this, { reason: this._streamError });
},
microphoneErrorCallback: (error) => this._reportMicrophoneError(error),
});
private _reportMicrophoneError(error?: string): void {
if (!this.targetID) {
return;
}
dispatchMicrophoneErrorEvent(this, {
targetID: this.targetID,
description: error,
});
}
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._activeSurface === 'image'
? this._imageSurface.getMediaPlayer()
@@ -237,11 +217,6 @@ export class AdvancedCameraCardGo2RTCExperimental
// Only the video surface has native controls; the image surface has none.
this._videoMediaPlayerController.setControls(this.controls).catch(() => {});
}
if (changedProps.has('microphoneStream')) {
// The WebRTC lane swaps the outbound track in place; no visible reload.
this._session.setMicrophoneStream(this.microphoneStream ?? null);
}
}
protected updated(): void {
@@ -15,7 +15,6 @@ import {
getSignedURLErrorText,
SignedURLController,
} from '../../../../components-lib/signed-url-controller.js';
import type { MicrophoneConfig } from '../../../../config/schema/live.js';
import type { HomeAssistant } from '../../../../ha/types.js';
import { localize } from '../../../../localize/localize.js';
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss?inline';
@@ -37,12 +36,6 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
@property({ attribute: false })
public targetID?: string;
@property({ attribute: false })
public microphoneStream?: MediaStream | null;
@property({ attribute: false })
public microphoneConfig?: MicrophoneConfig;
// The camera's title, shown in error messages to identify the camera.
@property({ attribute: false })
public cameraTitle?: string;
@@ -110,7 +103,6 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
this._player = new VideoRTC();
this._player.targetID = this.targetID ?? null;
this._player.mediaPlayerController = this._mediaPlayerController;
this._player.microphoneStream = this.microphoneStream ?? null;
this._player.src = src;
this._player.visibilityCheck = false;
this._player.setControls(this.controls);
@@ -143,13 +135,6 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
if (changedProps.has('controls') && this._player) {
this._player.setControls(this.controls);
}
if (this._player && changedProps.has('microphoneStream')) {
// VideoRTC owns the transition: it updates microphoneStream, swaps the
// track on the pre-armed transceiver, and validates against stale async
// completions before any reconnect fallback. Fire-and-forget is fine.
void this._player.setMicrophoneStream(this.microphoneStream ?? null);
}
}
protected render(): TemplateResult | void {
-2
View File
@@ -32,10 +32,8 @@ export class VideoRTC extends HTMLElement {
// Custom methods/members.
mediaPlayerController: MediaPlayerController | null;
microphoneStream: MediaStream | null;
targetID: string | null;
reconnect();
reset(): void;
setControls(controls: boolean): void;
setMicrophoneStream(stream: MediaStream | null): Promise<void>;
}
@@ -1,9 +1,5 @@
import { getTechnologyForVideoRTC } from '../../../../components-lib/live/utils/get-technology-for-video-rtc.js';
import {
addAudioTracksMuteStateListener,
has2WayAudio,
hasAudio,
} from '../../../../utils/audio';
import { addAudioTracksMuteStateListener, hasAudio } from '../../../../utils/audio';
import {
hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
@@ -159,21 +155,6 @@ export class VideoRTC extends HTMLElement {
*/
this.onmessage = null;
/**
* A microphone stream to attach to a WebRTC connection.
* @type {MediaStream}}
*/
this.microphoneStream = null;
/**
* The outbound audio transceiver pre-armed during createOffer. Holds a
* reference so `setMicrophoneStream` can swap the track via
* `replaceTrack` without renegotiating the SDP. Cleared on disconnect
* because transceivers belong to the closed peer connection.
* @type {RTCRtpTransceiver | null}
*/
this._microphoneTransceiver = null;
/**
* A reference to a MediaPlayerController for this video
* @type {MediaPlayerController | null}
@@ -216,7 +197,6 @@ export class VideoRTC extends HTMLElement {
mediaPlayerController: this.mediaPlayerController,
}),
capabilities: {
has2WayAudio: has2WayAudio(this.pc),
hasAudio: hasAudio(this.video, this.pc, this.mseCodecs),
supportsPause: true,
},
@@ -268,50 +248,6 @@ export class VideoRTC extends HTMLElement {
}
}
/**
* Owns the microphone stream transition end-to-end: updates the property,
* extracts the outbound audio track, and swaps it onto the pre-armed audio
* transceiver via `replaceTrack` -- no SDP renegotiation, no visible reload.
*
* Falls back to a full reconnect if `replaceTrack` rejects, but only when
* the rejection still describes the current desired state. The transceiver
* and the requested stream are captured before awaiting so a late rejection
* from a stale operation (e.g. after `reset()` cleared the connection, or
* after a newer `setMicrophoneStream` superseded this one) cannot bring the
* player back online or overwrite a fresher request.
*
* @param {MediaStream | null} stream
*/
async setMicrophoneStream(stream) {
if (this.microphoneStream === stream) {
return;
}
this.microphoneStream = stream;
const transceiver = this._microphoneTransceiver;
if (!transceiver) {
// No live peer connection yet (or createOffer hasn't run). The next
// createOffer will read `this.microphoneStream` and pre-arm the
// transceiver with the current track, so no separate fix-up is needed.
return;
}
const desiredTrack = stream?.getAudioTracks()[0] ?? null;
try {
await transceiver.sender.replaceTrack(desiredTrack);
} catch (er) {
const stillCurrent =
transceiver === this._microphoneTransceiver &&
this.microphoneStream === stream &&
this.pc !== null;
if (!stillCurrent) {
return;
}
console.warn(er);
this.reconnect();
}
}
/**
* Reconnect the stream.
*/
@@ -552,15 +488,9 @@ export class VideoRTC extends HTMLElement {
this.pcState = WebSocket.CLOSED;
if (this.pc) {
// Do not close the (microphone) track attached to the peer connection as
// that is controlled by MicrophoneManager.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1810
this.pc.close();
this.pc = null;
}
// Transceivers belong to the now-closed peer connection.
this._microphoneTransceiver = null;
this.video.src = '';
this.video.srcObject = null;
@@ -646,7 +576,6 @@ export class VideoRTC extends HTMLElement {
this.pc.close();
this.pc = null;
this.pcState = WebSocket.CLOSED;
this._microphoneTransceiver = null;
}
// reconnect no more than once every X seconds
@@ -861,25 +790,16 @@ export class VideoRTC extends HTMLElement {
* @return {Promise<RTCSessionDescriptionInit>}
*/
async createOffer(pc) {
// Always pre-arm a single outbound audio transceiver so the SDP advertises
// the slot from the start. With the slot in place, the mic track can be
// attached/detached later via `setMicrophoneStream` (replaceTrack) without
// renegotiating -- avoiding a visible reload of this cell each time grid
// selection moves the mic between cameras.
//
// Pure SDP allocation: the kind-only `addTransceiver('audio', ...)` form
// never calls `getUserMedia`, so users who don't grant mic access see no
// browser permission prompt from this path.
//
// The upstream `media.includes('microphone')` branch (which would have
// performed its own `getUserMedia` and added a second outbound audio
// transceiver) is intentionally omitted: the card drives mic acquisition
// through `MicrophoneManager`, and a second sender would race the one
// owned by `setMicrophoneStream`.
const micTrack = this.microphoneStream?.getAudioTracks()[0] ?? null;
this._microphoneTransceiver = pc.addTransceiver(micTrack ?? 'audio', {
direction: 'sendonly',
});
try {
if (this.media.includes('microphone')) {
const media = await navigator.mediaDevices.getUserMedia({ audio: true });
media.getTracks().forEach((track) => {
pc.addTransceiver(track, { direction: 'sendonly' });
});
}
} catch (e) {
console.warn(e);
}
for (const kind of ['video', 'audio']) {
if (this.media.indexOf(kind) >= 0) {
-1
View File
@@ -85,7 +85,6 @@ const microphoneConfigSchema = z
.default(microphoneConfigDefault.mute_after_microphone_mute_seconds),
})
.default(microphoneConfigDefault);
export type MicrophoneConfig = z.infer<typeof microphoneConfigSchema>;
export const liveConfigDefault = {
auto_play: [...MEDIA_ACTION_POSITIVE_CONDITIONS],
@@ -1,9 +1,9 @@
import type { EnabledProxyConfig } from '../../../config/schema/common/proxy';
import { homeAssistantSignAndFetch } from '../../../ha/fetch';
import type { HomeAssistant } from '../../../ha/types';
import { createProxiedEndpointIfNecessary } from '../../../ha/web-proxy';
import type { Endpoint } from '../../../types';
import { errorToConsole } from '../../../utils/basic';
import type { EnabledProxyConfig } from '../config/schema/common/proxy';
import { homeAssistantSignAndFetch } from '../ha/fetch';
import type { HomeAssistant } from '../ha/types';
import { createProxiedEndpointIfNecessary } from '../ha/web-proxy';
import type { Endpoint } from '../types';
import { errorToConsole } from '../utils/basic';
import { go2RTCStreamInfoSchema, type Go2RTCStreamInfo } from './types';
const getGo2RTCStreamMetadata = async (
@@ -1,5 +1,5 @@
import type { CameraConfig } from '../../../config/schema/cameras';
import type { Endpoint } from '../../../types';
import type { CameraConfig } from '../config/schema/cameras';
import type { Endpoint } from '../types';
interface EndpointOptions {
url?: string;
@@ -18,7 +18,7 @@ const buildGo2RTCEndpoint = (
return null;
}
const endpoint = pathBuilder(url, stream);
const endpoint = pathBuilder(url, encodeURIComponent(stream));
return {
endpoint,
// Only sign the endpoint if it's local to HA.
@@ -43,9 +43,9 @@ export const getGo2RTCMetadataEndpoint = (
): Endpoint | null => {
return buildGo2RTCEndpoint(
cameraConfig,
// Use probe parameters to trigger active stream detection.
// Without these, go2rtc only returns static config without producer medias.
(url, stream) => `${url}/api/streams?src=${stream}&video=all&audio=all&microphone`,
// The `video` and `audio` parameters make go2rtc connect to the camera and
// report what it finds vs just reporting its own configuration.
(url, stream) => `${url}/api/streams?src=${stream}&video=all&audio=all`,
options,
);
};
+27
View File
@@ -0,0 +1,27 @@
import { z } from 'zod';
// go2rtc control messages are JSON text frames of this shape; media flows as
// separate binary frames.
export const go2RTCMessageSchema = z.object({
type: z.string(),
// Per-type payload (a codec list, an SDP, an ICE candidate, error text, ...):
// absent for some types (e.g. mjpeg) and not always a string, so it is typed
// `unknown` and each handler narrows it before use.
value: z.unknown().optional(),
});
export type Go2RTCMessage = z.infer<typeof go2RTCMessageSchema>;
export type MessageCallback = (message: Go2RTCMessage) => void;
export type BinaryCallback = (data: ArrayBuffer) => void;
// The go2rtc server reports a mode failure as `{ type: 'error', value: '<mode>:
// ...' }` (e.g. `mse: stream not found`), so an error is for a given mode when
// its value starts with that mode's name.
export const isServerErrorForMode = (
message: Go2RTCMessage,
mode: string,
): message is Go2RTCMessage & { value: string } =>
message.type === 'error' &&
typeof message.value === 'string' &&
message.value.startsWith(mode);
@@ -1,10 +1,10 @@
import type { UnsubscribeCallback } from '../../../../types';
import type { UnsubscribeCallback } from '../types';
import {
go2RTCMessageSchema,
type BinaryCallback,
type Go2RTCMessage,
type MessageCallback,
} from './types';
} from './messages';
export type WebSocketFactory = (url: string) => WebSocket;
@@ -7,7 +7,7 @@ const go2RTCProducerSchema = z.object({
/**
* Zod schema for Go2RTC stream information. Schema only covers the minimum
* required by the card.
* Response from `/api/streams?src=${stream}&video=all&audio=all&microphone`
* Response from `/api/streams?src=${stream}&video=all&audio=all`
*/
export const go2RTCStreamInfoSchema = z.object({
producers: z.array(go2RTCProducerSchema).optional(),
+70
View File
@@ -0,0 +1,70 @@
import type { EnabledProxyConfig } from '../config/schema/common/proxy';
import type { Endpoint } from '../types';
import { errorToConsole } from '../utils/basic';
import { homeAssistantGetSignedURLIfNecessary } from './sign-path';
import type { HomeAssistant } from './types';
import {
createProxiedEndpointIfNecessary,
type CreateProxiedEndpointOptions,
} from './web-proxy';
export const PROXY_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
export type ResolvedEndpoint =
| { success: true; url: string }
| { success: false; error: 'proxy' | 'sign' };
// Turns an endpoint into a URL that can be fetched or connected to: proxied
// through Home Assistant when the proxy configuration calls for it, then signed
// when the result needs Home Assistant authentication.
export const resolveEndpointURL = async (
hass: HomeAssistant,
endpoint: Endpoint,
options?: {
proxyConfig?: EnabledProxyConfig | null;
proxyEndpointOptions?: CreateProxiedEndpointOptions;
},
): Promise<ResolvedEndpoint> => {
// Proxy registration and signing both need an absolute URL.
const absolute: Endpoint = {
endpoint: new URL(endpoint.endpoint, document.baseURI).toString(),
sign: endpoint.sign,
};
let proxied: Endpoint | null;
if (!options?.proxyConfig?.enabled) {
proxied = absolute;
} else {
try {
proxied = await createProxiedEndpointIfNecessary(
hass,
{ endpoint: absolute.endpoint, sign: false },
options.proxyConfig,
{
ttl: PROXY_URL_SIGN_EXPIRY_SECONDS,
openLimit: 0,
...options.proxyEndpointOptions,
},
);
} catch (e: unknown) {
errorToConsole(e);
proxied = null;
}
}
if (!proxied) {
return { success: false, error: 'proxy' };
}
let signed: string | null;
try {
signed = await homeAssistantGetSignedURLIfNecessary(
hass,
proxied,
PROXY_URL_SIGN_EXPIRY_SECONDS,
);
} catch (e: unknown) {
errorToConsole(e);
signed = null;
}
return signed ? { success: true, url: signed } : { success: false, error: 'sign' };
};
+1
View File
@@ -805,6 +805,7 @@
"error": {
"awaiting_live": "Waiting for live stream to load",
"awaiting_media": "Waiting for media to load",
"call_camera_unreachable": "The camera could not be reached for two-way audio.",
"call_invalid_target": "The requested camera or stream is not available to call.",
"call_microphone_failed": "Your microphone could not be connected.",
"call_microphone_forbidden": "Microphone access has been denied for this page. Update your browser permissions and try again.",
-5
View File
@@ -20,11 +20,6 @@ export interface MediaLoadedCapabilities {
supportsPause?: boolean;
hasAudio?: boolean;
// Note: This is whether the current stream already _has_ 2-way audio, not
// whether the underlying camera _could_ establish 2 way audio. For the
// latter, consult the camera's capabilities ('2-way-audio').
has2WayAudio?: boolean;
}
export type MediaTechnology =
-16
View File
@@ -68,22 +68,6 @@ export const hasAudio = (
return mayHaveAudio(video);
};
/**
* Check if a WebRTC peer connection has an outbound audio channel (i.e. 2-way
* audio / microphone support).
* @param pc The RTCPeerConnection to check.
* @returns True if the connection has an audio transceiver configured to send.
*/
export const has2WayAudio = (pc: RTCPeerConnection | null): boolean => {
return !!pc
?.getTransceivers()
.some(
(tr) =>
tr.sender.track?.kind === 'audio' &&
(tr.direction === 'sendonly' || tr.direction === 'sendrecv'),
);
};
export type AudioTracksMuteStateCleanup = (() => void) | null;
/**
+15
View File
@@ -88,6 +88,21 @@ export function contentsChanged(
return !isEqualWith(n, o, customizer);
}
// Get a description from thrown value preferring the browser's sentence ("The
// peer connection is closed") over the bare type name ("InvalidStateError").
//
// DOMException may not inherit from Error, and catch blocks may be handed
// anything, so details are extracted structurally rather than via `instanceof
// Error`.
export const getErrorDescription = (error: unknown): string | null => {
if (!isRecord(error)) {
return null;
}
const message = typeof error.message === 'string' ? error.message : '';
const name = typeof error.name === 'string' ? error.name : '';
return message || name || null;
};
/**
* Log an error as a warning to the console.
* @param e The caught error or error-like value.
+1 -1
View File
@@ -1,7 +1,7 @@
import { supports2WayAudio as gortcSupports2WayAudio } from '../camera-manager/utils/go2rtc/audio';
import type { CameraConfig } from '../config/schema/cameras';
import type { LiveProvider } from '../config/schema/cameras.js';
import type { EnabledProxyConfig } from '../config/schema/common/proxy';
import { supports2WayAudio as gortcSupports2WayAudio } from '../go2rtc/audio';
import type { HomeAssistant } from '../ha/types';
import type { Endpoint } from '../types';