Files
advanced-camera-card/tests/components-lib/live/backchannel/go2rtc.test.ts
T
Dermot Duffy 11cc543406 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
2026-08-21 20:13:58 -07:00

727 lines
26 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { Go2RTCBackchannel } from '../../../../src/components-lib/live/backchannel/go2rtc';
import type { BackchannelErrorCallback } from '../../../../src/components-lib/live/backchannel/types';
import {
resolveEndpointURL,
type ResolvedEndpoint,
} from '../../../../src/ha/resolve-endpoint';
import type { HomeAssistant } from '../../../../src/ha/types';
import {
FakeMediaStream,
FakeMediaStreamTrack,
FakeRTCPeerConnection,
FakeWebSocket,
} from '../../../go2rtc/test-utils';
import { flushPromises } from '../../../test-utils';
vi.mock('../../../../src/ha/resolve-endpoint');
const createStream = (): FakeMediaStream =>
new FakeMediaStream([new FakeMediaStreamTrack('audio')]);
const setup = (options?: { errorCallback?: BackchannelErrorCallback }) => {
const pc = new FakeRTCPeerConnection();
const websocket = new FakeWebSocket();
const backchannel = new Go2RTCBackchannel(
mock<HomeAssistant>(),
{ endpoint: '/local/api/ws?src=camera', sign: true },
undefined,
{
createPeerConnection: () => pc.asPeerConnection(),
createWebSocket: () => websocket.asWebSocket(),
...(options?.errorCallback && { errorCallback: options.errorCallback }),
},
);
return { backchannel, pc, websocket };
};
// Drives a successful negotiation up to (but not including) the point the
// caller chooses to complete or fail it.
const negotiate = async (websocket: FakeWebSocket) => {
await flushPromises();
websocket.fireOpen();
await flushPromises();
websocket.fireMessage(JSON.stringify({ type: 'webrtc/answer', value: 'v=0\r\n' }));
await flushPromises();
};
const connect = async (pc: FakeRTCPeerConnection, websocket: FakeWebSocket) => {
await negotiate(websocket);
pc.fireConnectionStateChange('connected');
await flushPromises();
};
// @vitest-environment jsdom
describe('Go2RTCBackchannel', () => {
beforeEach(() => {
vi.mocked(resolveEndpointURL).mockResolvedValue({
success: true,
url: 'http://go2rtc/api/ws',
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('start', () => {
it('should offer exactly one outbound audio slot and no video', async () => {
const { backchannel, pc, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await connect(pc, websocket);
await started;
expect(pc.transceivers).toHaveLength(1);
expect(pc.transceivers[0].direction).toBe('sendonly');
expect(pc.transceivers[0].sender.track?.kind).toBe('audio');
});
it('should resolve only once the camera is reachable', async () => {
const { backchannel, pc, websocket } = setup();
let resolved = false;
const started = backchannel.start(createStream().asMediaStream()).then(() => {
resolved = true;
});
await negotiate(websocket);
expect(resolved).toBe(false);
pc.fireConnectionStateChange('connected');
await started;
expect(resolved).toBe(true);
});
it('should send the offer over the signaling channel', async () => {
const { backchannel, pc, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await connect(pc, websocket);
await started;
expect(websocket.sent.map((message) => JSON.parse(message).type)).toContain(
'webrtc/offer',
);
});
it('should reject when the microphone has no audio track', async () => {
const { backchannel } = setup();
await expect(
backchannel.start(new FakeMediaStream().asMediaStream()),
).rejects.toMatchObject({ reason: 'no_microphone' });
});
it('should reject when the microphone track has already ended', async () => {
const { backchannel } = setup();
const track = new FakeMediaStreamTrack('audio');
track.readyState = 'ended';
await expect(
backchannel.start(new FakeMediaStream([track]).asMediaStream()),
).rejects.toMatchObject({ reason: 'no_microphone' });
});
it('should reject when the address cannot be resolved', async () => {
vi.mocked(resolveEndpointURL).mockResolvedValue({
success: false,
error: 'proxy',
});
const { backchannel } = setup();
await expect(
backchannel.start(createStream().asMediaStream()),
).rejects.toMatchObject({ reason: 'failed', description: 'proxy' });
});
it('should reject when the server reports the stream cannot take audio', async () => {
const { backchannel, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
websocket.fireMessage(
JSON.stringify({ type: 'error', value: 'webrtc: no backchannel' }),
);
await expect(started).rejects.toMatchObject({
reason: 'no_two_way_audio',
description: 'webrtc: no backchannel',
});
});
it('should reject when the camera declines to receive audio', async () => {
const { backchannel, pc, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await negotiate(websocket);
pc.getMicrophoneTransceiver().currentDirection = 'inactive';
pc.fireConnectionStateChange('connected');
await expect(started).rejects.toMatchObject({ reason: 'no_two_way_audio' });
});
it('should reject when the signaling channel closes before connecting', async () => {
const { backchannel, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
websocket.fireClose();
await expect(started).rejects.toMatchObject({ reason: 'failed' });
});
it('should reject when the peer connection fails', async () => {
const { backchannel, pc, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await negotiate(websocket);
pc.fireConnectionStateChange('failed');
await expect(started).rejects.toMatchObject({ reason: 'failed' });
});
it('should reject when the camera is not reached in time', async () => {
vi.useFakeTimers();
const { backchannel, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
vi.advanceTimersByTime(10 * 1000);
await expect(started).rejects.toMatchObject({ reason: 'failed' });
vi.useRealTimers();
});
it('should reject when the offer cannot be created', async () => {
const { backchannel, pc, websocket } = setup();
pc.createOffer.mockRejectedValue(new Error('no media'));
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
await expect(started).rejects.toMatchObject({
reason: 'failed',
description: 'no media',
});
});
it('should close the signaling channel once connected', async () => {
const { backchannel, pc, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await connect(pc, websocket);
await started;
expect(websocket.close).toHaveBeenCalled();
expect(pc.close).not.toHaveBeenCalled();
});
it('should send ICE candidates and signal the end of them', async () => {
const { backchannel, pc, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
pc.fireIceCandidate('candidate:1');
pc.fireIceCandidate(null);
const candidates = websocket.sent
.map((message) => JSON.parse(message))
.filter((message) => message.type === 'webrtc/candidate')
.map((message) => message.value);
expect(candidates).toEqual(['candidate:1', '']);
await connect(pc, websocket);
await started;
});
it('should apply candidates from the server', async () => {
const { backchannel, pc, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
websocket.fireMessage(
JSON.stringify({ type: 'webrtc/candidate', value: 'candidate:2' }),
);
await flushPromises();
expect(pc.addIceCandidate).toHaveBeenCalledWith({
candidate: 'candidate:2',
sdpMid: '0',
});
await connect(pc, websocket);
await started;
});
});
describe('after connecting', () => {
it('should report a peer connection that later fails', async () => {
const errorCallback = vi.fn();
const { backchannel, pc, websocket } = setup({ errorCallback });
const started = backchannel.start(createStream().asMediaStream());
await connect(pc, websocket);
await started;
pc.fireConnectionStateChange('failed');
expect(errorCallback).toHaveBeenCalledWith(
expect.objectContaining({ reason: 'failed' }),
);
});
it('should release the camera when the peer connection later fails', async () => {
const stream = createStream();
const { backchannel, pc, websocket } = setup({ errorCallback: vi.fn() });
const started = backchannel.start(stream.asMediaStream());
await connect(pc, websocket);
await started;
pc.fireConnectionStateChange('failed');
expect(pc.close).toHaveBeenCalled();
expect(stream.getAudioTracks()[0].readyState).toBe('live');
});
it('should ignore the signaling channel closing', async () => {
const errorCallback = vi.fn();
const { backchannel, pc, websocket } = setup({ errorCallback });
const started = backchannel.start(createStream().asMediaStream());
await connect(pc, websocket);
await started;
websocket.fireClose();
expect(errorCallback).not.toHaveBeenCalled();
});
});
describe('setStream', () => {
it('should swap the outbound track without reconnecting', async () => {
const { backchannel, pc, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await connect(pc, websocket);
await started;
const replacement = createStream();
await backchannel.setStream(replacement.asMediaStream());
expect(pc.getMicrophoneTransceiver().sender.replaceTrack).toHaveBeenCalledWith(
replacement.getAudioTracks()[0],
);
expect(pc.close).not.toHaveBeenCalled();
});
it('should do nothing without an established path', async () => {
const { backchannel } = setup();
await expect(
backchannel.setStream(createStream().asMediaStream()),
).resolves.toBeUndefined();
});
});
describe('stop', () => {
it('should close the connection but leave the microphone running', async () => {
const { backchannel, pc, websocket } = setup();
const stream = createStream();
const started = backchannel.start(stream.asMediaStream());
await connect(pc, websocket);
await started;
backchannel.stop();
expect(pc.close).toHaveBeenCalled();
expect(stream.getAudioTracks()[0].readyState).toBe('live');
});
it('should not report a peer connection that fails after being stopped', async () => {
const errorCallback = vi.fn();
const { backchannel, pc, websocket } = setup({ errorCallback });
const started = backchannel.start(createStream().asMediaStream());
await negotiate(websocket);
backchannel.stop();
pc.fireConnectionStateChange('failed');
await flushPromises();
await expect(started).rejects.toMatchObject({ reason: 'abandoned' });
expect(errorCallback).not.toHaveBeenCalled();
});
});
describe('losing the microphone', () => {
const endTrack = (stream: FakeMediaStream): void => {
const track = stream.getAudioTracks()[0];
track.readyState = 'ended';
track.dispatchEvent(new Event('ended'));
};
it('should fail a start whose microphone ends before connecting', async () => {
const stream = createStream();
const { backchannel, websocket } = setup();
const started = backchannel.start(stream.asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
endTrack(stream);
await expect(started).rejects.toMatchObject({ reason: 'no_microphone' });
});
it('should release the camera and report when the microphone ends mid-call', async () => {
const errorCallback = vi.fn();
const stream = createStream();
const { backchannel, pc, websocket } = setup({ errorCallback });
const started = backchannel.start(stream.asMediaStream());
await connect(pc, websocket);
await started;
endTrack(stream);
expect(pc.close).toHaveBeenCalled();
expect(errorCallback).toHaveBeenCalledWith(
expect.objectContaining({ reason: 'no_microphone' }),
);
});
it('should stop watching a microphone that has been replaced', async () => {
const stream = createStream();
const errorCallback = vi.fn();
const { backchannel, pc, websocket } = setup({ errorCallback });
const started = backchannel.start(stream.asMediaStream());
await connect(pc, websocket);
await started;
await backchannel.setStream(createStream().asMediaStream());
endTrack(stream);
expect(errorCallback).not.toHaveBeenCalled();
expect(pc.close).not.toHaveBeenCalled();
});
it('should stop watching the microphone once stopped', async () => {
const stream = createStream();
const errorCallback = vi.fn();
const { backchannel, pc, websocket } = setup({ errorCallback });
const started = backchannel.start(stream.asMediaStream());
await connect(pc, websocket);
await started;
backchannel.stop();
endTrack(stream);
expect(errorCallback).not.toHaveBeenCalled();
});
});
describe('stale and defensive paths', () => {
it('should use the browser factories when none are supplied', async () => {
const pc = new FakeRTCPeerConnection();
const websocket = new FakeWebSocket();
vi.stubGlobal('RTCPeerConnection', function () {
return pc.asPeerConnection();
});
vi.stubGlobal('WebSocket', function () {
return websocket.asWebSocket();
});
const backchannel = new Go2RTCBackchannel(mock<HomeAssistant>(), {
endpoint: '/local/api/ws?src=camera',
});
const started = backchannel.start(createStream().asMediaStream());
await connect(pc, websocket);
await started;
expect(pc.transceivers).toHaveLength(1);
vi.unstubAllGlobals();
});
it('should time out an address resolution that never returns', async () => {
vi.useFakeTimers();
vi.mocked(resolveEndpointURL).mockReturnValue(new Promise(() => {}));
const { backchannel } = setup();
const started = backchannel.start(createStream().asMediaStream());
vi.advanceTimersByTime(10 * 1000);
await expect(started).rejects.toMatchObject({ reason: 'failed' });
vi.useRealTimers();
});
it('should reject with a reason and release the connection when setup throws', async () => {
const { backchannel, pc } = setup();
pc.addTransceiver = () => {
throw new Error('bad track');
};
await expect(
backchannel.start(createStream().asMediaStream()),
).rejects.toMatchObject({ reason: 'failed', description: 'bad track' });
expect(pc.close).toHaveBeenCalled();
});
it('should reject without a description when setup throws something not error-like', async () => {
const { backchannel, pc } = setup();
pc.addTransceiver = () => {
throw 'a bare string';
};
await expect(
backchannel.start(createStream().asMediaStream()),
).rejects.toMatchObject({ reason: 'failed', description: null });
});
it('should abandon a start stopped while the address resolves', async () => {
let release: (value: ResolvedEndpoint) => void = () => {};
vi.mocked(resolveEndpointURL).mockReturnValue(
new Promise((resolve) => {
release = resolve;
}),
);
const { backchannel, pc } = setup();
const started = backchannel.start(createStream().asMediaStream());
backchannel.stop();
release({ success: true, url: 'http://go2rtc/api/ws' });
await flushPromises();
await expect(started).rejects.toMatchObject({ reason: 'abandoned' });
expect(pc.transceivers).toHaveLength(0);
});
it('should not send candidates after being stopped', async () => {
const { backchannel, pc, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
const before = websocket.sent.length;
backchannel.stop();
pc.fireIceCandidate('candidate:late');
expect(websocket.sent).toHaveLength(before);
await expect(started).rejects.toThrow();
});
it('should not negotiate after being stopped', async () => {
const { backchannel, pc, websocket } = setup();
let releaseOffer: (value: { type: string; sdp?: string }) => void = () => {};
pc.createOffer.mockReturnValue(
new Promise((resolve) => {
releaseOffer = resolve;
}),
);
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
backchannel.stop();
releaseOffer({ type: 'offer', sdp: 'v=0' });
await flushPromises();
expect(pc.setLocalDescription).not.toHaveBeenCalled();
await expect(started).rejects.toThrow();
});
it('should not send an offer when the local description is stopped mid-flight', async () => {
const { backchannel, pc, websocket } = setup();
let releaseLocal: () => void = () => {};
pc.setLocalDescription.mockReturnValue(
new Promise<void>((resolve) => {
releaseLocal = resolve;
}),
);
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
backchannel.stop();
releaseLocal();
await flushPromises();
expect(
websocket.sent.filter((m) => JSON.parse(m).type === 'webrtc/offer'),
).toHaveLength(0);
await expect(started).rejects.toThrow();
});
it('should send an empty offer when the browser produces no SDP', async () => {
const { backchannel, pc, websocket } = setup();
pc.createOffer.mockResolvedValue({ type: 'offer' });
const started = backchannel.start(createStream().asMediaStream());
await connect(pc, websocket);
await started;
const offer = websocket.sent
.map((m) => JSON.parse(m))
.find((m) => m.type === 'webrtc/offer');
expect(offer.value).toBe('');
});
it('should reject without a description when a negotiation failure is not error-like', async () => {
const { backchannel, pc, websocket } = setup();
pc.createOffer.mockRejectedValue('a bare string');
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
await expect(started).rejects.toMatchObject({
reason: 'failed',
description: null,
});
});
it('should describe a negotiation failure by its name when it carries no message', async () => {
const { backchannel, pc, websocket } = setup();
pc.createOffer.mockRejectedValue({ name: 'InvalidStateError' });
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
await expect(started).rejects.toMatchObject({
reason: 'failed',
description: 'InvalidStateError',
});
});
it('should reject when the answer cannot be applied', async () => {
const { backchannel, pc, websocket } = setup();
pc.setRemoteDescription.mockRejectedValue(new Error('bad sdp'));
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
websocket.fireMessage(JSON.stringify({ type: 'webrtc/answer', value: 'v=0' }));
await expect(started).rejects.toMatchObject({
reason: 'failed',
description: 'bad sdp',
});
});
it('should ignore messages without a string payload', async () => {
const { backchannel, pc, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
websocket.fireMessage(JSON.stringify({ type: 'webrtc/answer', value: 42 }));
await flushPromises();
expect(pc.setRemoteDescription).not.toHaveBeenCalled();
await connect(pc, websocket);
await started;
});
it('should tolerate a candidate the browser rejects', async () => {
const { backchannel, pc, websocket } = setup();
pc.addIceCandidate.mockRejectedValue(new Error('bad candidate'));
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
websocket.fireMessage(
JSON.stringify({ type: 'webrtc/candidate', value: 'candidate:3' }),
);
await flushPromises();
await connect(pc, websocket);
await expect(started).resolves.toBeUndefined();
});
it('should ignore an empty candidate from the server', async () => {
const { backchannel, pc, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
websocket.fireMessage(JSON.stringify({ type: 'webrtc/candidate', value: '' }));
await flushPromises();
expect(pc.addIceCandidate).not.toHaveBeenCalled();
await connect(pc, websocket);
await started;
});
it('should ignore messages after being stopped', async () => {
const { backchannel, pc, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
backchannel.stop();
websocket.fireMessage(JSON.stringify({ type: 'webrtc/answer', value: 'v=0' }));
await flushPromises();
expect(pc.setRemoteDescription).not.toHaveBeenCalled();
await expect(started).rejects.toThrow();
});
it('should ignore intermediate connection states', async () => {
const { backchannel, pc, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await negotiate(websocket);
pc.fireConnectionStateChange('connecting');
await flushPromises();
expect(websocket.close).not.toHaveBeenCalled();
pc.fireConnectionStateChange('connected');
await started;
});
it('should reject a replacement carrying no audio', async () => {
const { backchannel, pc, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await connect(pc, websocket);
await started;
await expect(
backchannel.setStream(new FakeMediaStream().asMediaStream()),
).rejects.toMatchObject({ reason: 'no_microphone' });
expect(pc.getMicrophoneTransceiver().sender.replaceTrack).not.toHaveBeenCalled();
});
it('should reject a replacement whose track has ended', async () => {
const { backchannel, pc, websocket } = setup();
const started = backchannel.start(createStream().asMediaStream());
await connect(pc, websocket);
await started;
const track = new FakeMediaStreamTrack('audio');
track.readyState = 'ended';
await expect(
backchannel.setStream(new FakeMediaStream([track]).asMediaStream()),
).rejects.toMatchObject({ reason: 'no_microphone' });
});
it('should reject without a description when an answer failure names nothing', async () => {
const { backchannel, pc, websocket } = setup();
pc.setRemoteDescription.mockRejectedValue({});
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
websocket.fireMessage(JSON.stringify({ type: 'webrtc/answer', value: 'v=0' }));
await expect(started).rejects.toMatchObject({
reason: 'failed',
description: null,
});
});
it('should ignore a negotiation failure that arrives after being stopped', async () => {
const { backchannel, pc, websocket } = setup();
let rejectOffer: (error: unknown) => void = () => {};
pc.createOffer.mockReturnValue(
new Promise((_resolve, reject) => {
rejectOffer = reject;
}),
);
const started = backchannel.start(createStream().asMediaStream());
await flushPromises();
websocket.fireOpen();
await flushPromises();
backchannel.stop();
await expect(started).rejects.toMatchObject({ reason: 'abandoned' });
rejectOffer(new Error('too late'));
await flushPromises();
});
});
});