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 (`µphone`), 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
255 lines
7.9 KiB
TypeScript
255 lines
7.9 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { SignalingChannel } from '../../src/go2rtc/signaling';
|
|
import { FakeWebSocket } from './test-utils';
|
|
|
|
describe('SignalingChannel', () => {
|
|
const setup = (options?: {
|
|
openCallback?: () => void;
|
|
disconnectCallback?: () => void;
|
|
}) => {
|
|
const websockets: FakeWebSocket[] = [];
|
|
const createWebSocket = vi.fn<(url: string) => WebSocket>(() => {
|
|
const websocket = new FakeWebSocket();
|
|
websockets.push(websocket);
|
|
return websocket.asWebSocket();
|
|
});
|
|
const channel = new SignalingChannel(
|
|
'ws://host/api/ws?src=camera',
|
|
{
|
|
openCallback: options?.openCallback,
|
|
disconnectCallback: options?.disconnectCallback,
|
|
},
|
|
{ createWebSocket },
|
|
);
|
|
return { channel, createWebSocket, websockets };
|
|
};
|
|
|
|
it('should connect with an arraybuffer binary type', () => {
|
|
const { channel, createWebSocket, websockets } = setup();
|
|
channel.connect();
|
|
|
|
expect(createWebSocket).toHaveBeenCalledWith('ws://host/api/ws?src=camera');
|
|
expect(websockets[0].binaryType).toBe('arraybuffer');
|
|
});
|
|
|
|
it('should not connect twice', () => {
|
|
const { channel, createWebSocket } = setup();
|
|
channel.connect();
|
|
channel.connect();
|
|
|
|
expect(createWebSocket).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should report open state and call the open callback', () => {
|
|
const openCallback = vi.fn();
|
|
const { channel, websockets } = setup({ openCallback });
|
|
channel.connect();
|
|
|
|
expect(channel.isOpen()).toBe(false);
|
|
|
|
websockets[0].fireOpen();
|
|
|
|
expect(channel.isOpen()).toBe(true);
|
|
expect(openCallback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should tolerate an absent open callback', () => {
|
|
const { channel, websockets } = setup();
|
|
channel.connect();
|
|
|
|
expect(() => websockets[0].fireOpen()).not.toThrow();
|
|
});
|
|
|
|
it('should not send before the connection is open', () => {
|
|
const { channel, websockets } = setup();
|
|
channel.connect();
|
|
channel.send({ type: 'mse', value: 'codecs' });
|
|
|
|
expect(websockets[0].send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should send JSON once open', () => {
|
|
const { channel, websockets } = setup();
|
|
channel.connect();
|
|
websockets[0].fireOpen();
|
|
const message = { type: 'mse', value: 'codecs' };
|
|
channel.send(message);
|
|
|
|
expect(websockets[0].sent).toEqual([JSON.stringify(message)]);
|
|
});
|
|
|
|
it('should dispatch parsed messages to subscribers', () => {
|
|
const { channel, websockets } = setup();
|
|
const callback = vi.fn();
|
|
channel.subscribeToMessages(callback);
|
|
channel.connect();
|
|
websockets[0].fireMessage('{"type":"mse","value":"video/mp4"}');
|
|
|
|
expect(callback).toHaveBeenCalledWith({ type: 'mse', value: 'video/mp4' });
|
|
});
|
|
|
|
it('should stop dispatching after unsubscribe', () => {
|
|
const { channel, websockets } = setup();
|
|
const callback = vi.fn();
|
|
const unsubscribe = channel.subscribeToMessages(callback);
|
|
channel.connect();
|
|
unsubscribe();
|
|
websockets[0].fireMessage('{"type":"mse"}');
|
|
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should dispatch to remaining subscribers when one unsubscribes during dispatch', () => {
|
|
const { channel, websockets } = setup();
|
|
const secondCallback = vi.fn();
|
|
const unsubscribeDuringDispatch = vi.fn((): void => {
|
|
unsubscribe();
|
|
});
|
|
const unsubscribe = channel.subscribeToMessages(unsubscribeDuringDispatch);
|
|
channel.subscribeToMessages(secondCallback);
|
|
channel.connect();
|
|
websockets[0].fireMessage('{"type":"mse"}');
|
|
|
|
expect(unsubscribeDuringDispatch).toHaveBeenCalledTimes(1);
|
|
expect(secondCallback).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should ignore invalid JSON', () => {
|
|
const { channel, websockets } = setup();
|
|
const callback = vi.fn();
|
|
channel.subscribeToMessages(callback);
|
|
channel.connect();
|
|
websockets[0].fireMessage('NOT JSON');
|
|
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should ignore malformed messages', () => {
|
|
const { channel, websockets } = setup();
|
|
const callback = vi.fn();
|
|
channel.subscribeToMessages(callback);
|
|
channel.connect();
|
|
websockets[0].fireMessage('{"type":6}');
|
|
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should ignore unexpected data types', () => {
|
|
const { channel, websockets } = setup();
|
|
const callback = vi.fn();
|
|
channel.subscribeToMessages(callback);
|
|
channel.connect();
|
|
websockets[0].fireMessage(42);
|
|
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should route binary data to the binary callback', () => {
|
|
const { channel, websockets } = setup();
|
|
const binaryCallback = vi.fn();
|
|
channel.setBinaryCallback(binaryCallback);
|
|
channel.connect();
|
|
const data = new ArrayBuffer(8);
|
|
websockets[0].fireMessage(data);
|
|
|
|
expect(binaryCallback).toHaveBeenCalledWith(data);
|
|
});
|
|
|
|
it('should drop binary data without a binary callback', () => {
|
|
const { channel, websockets } = setup();
|
|
channel.connect();
|
|
|
|
expect(() => websockets[0].fireMessage(new ArrayBuffer(8))).not.toThrow();
|
|
});
|
|
|
|
it('should drop binary data after the binary callback is cleared', () => {
|
|
const { channel, websockets } = setup();
|
|
const binaryCallback = vi.fn();
|
|
channel.setBinaryCallback(binaryCallback);
|
|
channel.setBinaryCallback(null);
|
|
channel.connect();
|
|
websockets[0].fireMessage(new ArrayBuffer(8));
|
|
|
|
expect(binaryCallback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should close the underlying websocket without firing the disconnect callback', () => {
|
|
const disconnectCallback = vi.fn();
|
|
const { channel, websockets } = setup({ disconnectCallback });
|
|
channel.connect();
|
|
websockets[0].fireOpen();
|
|
channel.close();
|
|
|
|
expect(websockets[0].close).toHaveBeenCalled();
|
|
expect(channel.isOpen()).toBe(false);
|
|
expect(disconnectCallback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should tolerate closing when never connected', () => {
|
|
const { channel } = setup();
|
|
|
|
expect(() => channel.close()).not.toThrow();
|
|
});
|
|
|
|
it('should ignore websocket events delivered after close', () => {
|
|
const openCallback = vi.fn();
|
|
const disconnectCallback = vi.fn();
|
|
const messageCallback = vi.fn();
|
|
const { channel, websockets } = setup({ openCallback, disconnectCallback });
|
|
channel.subscribeToMessages(messageCallback);
|
|
channel.connect();
|
|
channel.close();
|
|
|
|
websockets[0].fireOpen();
|
|
websockets[0].fireMessage('{"type":"mse"}');
|
|
websockets[0].fireClose();
|
|
|
|
expect(openCallback).not.toHaveBeenCalled();
|
|
expect(messageCallback).not.toHaveBeenCalled();
|
|
expect(disconnectCallback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should fire the disconnect callback on unexpected closure', () => {
|
|
const disconnectCallback = vi.fn();
|
|
const { channel, websockets } = setup({ disconnectCallback });
|
|
channel.connect();
|
|
websockets[0].fireOpen();
|
|
websockets[0].fireClose();
|
|
|
|
expect(disconnectCallback).toHaveBeenCalledTimes(1);
|
|
expect(channel.isOpen()).toBe(false);
|
|
});
|
|
|
|
it('should tolerate an absent disconnect callback on unexpected closure', () => {
|
|
const { channel, websockets } = setup();
|
|
channel.connect();
|
|
|
|
expect(() => websockets[0].fireClose()).not.toThrow();
|
|
});
|
|
|
|
it('should allow reconnecting after unexpected closure', () => {
|
|
const { channel, createWebSocket, websockets } = setup();
|
|
channel.connect();
|
|
websockets[0].fireClose();
|
|
channel.connect();
|
|
|
|
expect(createWebSocket).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should construct a real websocket by default', () => {
|
|
// A mock implementation must be callable with `new`, so it cannot be an
|
|
// arrow function.
|
|
const webSocketConstructor = vi.fn(function () {
|
|
return new FakeWebSocket().asWebSocket();
|
|
});
|
|
vi.stubGlobal('WebSocket', webSocketConstructor);
|
|
const channel = new SignalingChannel('ws://host/api/ws', {});
|
|
channel.connect();
|
|
channel.close();
|
|
|
|
expect(webSocketConstructor).toHaveBeenCalledWith('ws://host/api/ws');
|
|
vi.unstubAllGlobals();
|
|
});
|
|
});
|