feat: Add experimental rewrite of go2rtc live provider (MSE/WebRTC/MP4/MJPEG) (#2580)

- Closes #2556
- Closes #2450

**Key intended features:**
 - go2rtc compatible
- 100% test coverage to significantly improve ability to test, maintain
and work around browser weirdnesses (e.g. Safari).
 - Written from the ground up in the style of the rest of the project.

**To use:**
 - Change `live_provider` from `go2rtc` to `go2rtc-experimental`.
This commit is contained in:
Dermot Duffy
2026-07-14 14:22:41 -07:00
committed by GitHub
parent 5662e48c22
commit c02c692f68
125 changed files with 9608 additions and 807 deletions
+18 -14
View File
@@ -99,7 +99,7 @@ describe('hasAudio', () => {
createMockReceiver('video'),
createMockReceiver('audio', false),
]);
expect(hasAudio(createMockVideo(), pc, '')).toBe(true);
expect(hasAudio(createMockVideo(), { pc })).toBe(true);
});
it('should not detect audio when audio receiver is muted', () => {
@@ -107,19 +107,19 @@ describe('hasAudio', () => {
createMockReceiver('video'),
createMockReceiver('audio', true),
]);
expect(hasAudio(createMockVideo(), pc, '')).toBe(false);
expect(hasAudio(createMockVideo(), { pc })).toBe(false);
});
it('should not detect audio when only video receivers exist', () => {
const pc = createMockPeerConnection([createMockReceiver('video')]);
expect(hasAudio(createMockVideo(), pc, '')).toBe(false);
expect(hasAudio(createMockVideo(), { pc })).toBe(false);
});
it('should fall back to mayHaveAudio when no receivers yet', () => {
const pc = createMockPeerConnection([]);
// Empty receivers means connection not established, falls back to mayHaveAudio
// With no properties set on video, mayHaveAudio returns true (generous default)
expect(hasAudio(createMockVideo(), pc, '')).toBe(true);
expect(hasAudio(createMockVideo(), { pc })).toBe(true);
});
it('should ignore receivers when connection is not established', () => {
@@ -127,47 +127,51 @@ describe('hasAudio', () => {
// Stale connection with muted receiver should fall through to
// mayHaveAudio (generous default returns true)
expect(hasAudio(createMockVideo(), pc, '')).toBe(true);
expect(hasAudio(createMockVideo(), { pc })).toBe(true);
});
it('should fall through to mseCodecs when connection is not established', () => {
const pc = createMockPeerConnection([createMockReceiver('audio', true)], 'new');
// Stale WebRTC connection but MSE has audio codecs
expect(hasAudio(createMockVideo(), pc, 'avc1.640029,flac')).toBe(true);
expect(hasAudio(createMockVideo(), { pc, mseCodecs: 'avc1.640029,flac' })).toBe(
true,
);
});
});
describe('MSE codec detection', () => {
it('should detect audio when mseCodecs contains mp4a', () => {
expect(hasAudio(createMockVideo(), null, 'avc1.640029,mp4a.40.2')).toBe(true);
expect(hasAudio(createMockVideo(), { mseCodecs: 'avc1.640029,mp4a.40.2' })).toBe(
true,
);
});
it('should detect audio when mseCodecs contains opus', () => {
expect(hasAudio(createMockVideo(), null, 'avc1.640029,opus')).toBe(true);
expect(hasAudio(createMockVideo(), { mseCodecs: 'avc1.640029,opus' })).toBe(true);
});
it('should detect audio when mseCodecs contains flac', () => {
expect(hasAudio(createMockVideo(), null, 'avc1.640029,flac')).toBe(true);
expect(hasAudio(createMockVideo(), { mseCodecs: 'avc1.640029,flac' })).toBe(true);
});
it('should not detect audio when mseCodecs contains only video codecs', () => {
expect(hasAudio(createMockVideo(), null, 'avc1.640029,hvc1.1.6.L153.B0')).toBe(
false,
);
expect(
hasAudio(createMockVideo(), { mseCodecs: 'avc1.640029,hvc1.1.6.L153.B0' }),
).toBe(false);
});
});
describe('fallback to mayHaveAudio', () => {
it('should fall back to mayHaveAudio when no SDP or mseCodecs', () => {
// With no properties set, mayHaveAudio returns true (generous default)
expect(hasAudio(createMockVideo(), null, '')).toBe(true);
expect(hasAudio(createMockVideo())).toBe(true);
});
it('should use mayHaveAudio when mozHasAudio is false', () => {
const video = createMockVideo();
video.mozHasAudio = false;
expect(hasAudio(video, null, '')).toBe(false);
expect(hasAudio(video)).toBe(false);
});
});
});
-21
View File
@@ -9,7 +9,6 @@ import {
arrayMove,
aspectRatioToStyle,
contentsChanged,
convertHTTPAdressToWebsocket,
dayToDate,
desparsifyArrays,
errorToConsole,
@@ -561,26 +560,6 @@ describe('ignoreFunctionIdentity', () => {
});
});
describe('convertHTTPAdressToWebsocket', () => {
it('should convert http to ws', () => {
expect(convertHTTPAdressToWebsocket('http://example.com')).toBe('ws://example.com');
});
it('should convert https to ws', () => {
expect(convertHTTPAdressToWebsocket('https://example.com')).toBe(
'wss://example.com',
);
});
it('should not change ws url', () => {
expect(convertHTTPAdressToWebsocket('ws://example.com')).toBe('ws://example.com');
});
it('should not change wss url', () => {
expect(convertHTTPAdressToWebsocket('wss://example.com')).toBe('wss://example.com');
});
it('should handle mixed case', () => {
expect(convertHTTPAdressToWebsocket('HtTp://example.com')).toBe('ws://example.com');
});
});
describe('forceReflow', () => {
it('should access offsetHeight to trigger reflow', () => {
const element = document.createElement('div');
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { Generation } from '../../../src/utils/concurrency/generation';
describe('Generation', () => {
it('should treat a fresh snapshot as current', () => {
const generation = new Generation();
expect(generation.isCurrent(generation.current())).toBe(true);
});
it('should keep a snapshot current across further snapshots', () => {
const generation = new Generation();
const token = generation.current();
generation.current();
expect(generation.isCurrent(token)).toBe(true);
});
it('should invalidate outstanding snapshots', () => {
const generation = new Generation();
const token = generation.current();
generation.invalidate();
expect(generation.isCurrent(token)).toBe(false);
});
it('should invalidate prior tokens when starting a new operation', () => {
const generation = new Generation();
const first = generation.next();
const second = generation.next();
expect(generation.isCurrent(first)).toBe(false);
expect(generation.isCurrent(second)).toBe(true);
});
});
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest';
import { LatestValueRunner } from '../../../src/utils/concurrency/latest-value-runner';
interface Deferred {
promise: Promise<void>;
resolve: () => void;
reject: (reason?: unknown) => void;
}
const createDeferred = (): Deferred => {
let resolve: () => void = () => {};
let reject: (reason?: unknown) => void = () => {};
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
// A runner whose operation blocks on an external gate per run, so tests control
// exactly when each run completes.
const createGatedRunner = (): {
runner: LatestValueRunner<string>;
runs: string[];
gates: Deferred[];
} => {
const runs: string[] = [];
const gates: Deferred[] = [];
const runner = new LatestValueRunner<string>((value) => {
runs.push(value);
const gate = createDeferred();
gates.push(gate);
return gate.promise;
});
return { runner, runs, gates };
};
describe('LatestValueRunner', () => {
it('should resolve the submit promise once the run completes', async () => {
const gate = createDeferred();
const runner = new LatestValueRunner<string>(() => gate.promise);
let resolved = false;
const submitted = runner.submit('a').then(() => {
resolved = true;
});
await Promise.resolve();
expect(resolved).toBe(false);
gate.resolve();
await submitted;
expect(resolved).toBe(true);
});
it('should run each value when submitted while idle', async () => {
const runs: string[] = [];
const runner = new LatestValueRunner<string>((value) => {
runs.push(value);
return Promise.resolve();
});
await runner.submit('a');
await runner.submit('b');
expect(runs).toEqual(['a', 'b']);
});
it('should run one value at a time and drop all but the newest while busy', async () => {
const { runner, runs, gates } = createGatedRunner();
const a = runner.submit('a');
const b = runner.submit('b');
const c = runner.submit('c');
// Only the first run has started; the rest are pending.
expect(runs).toEqual(['a']);
gates[0].resolve();
await Promise.all([a, b, c]);
// 'b' was superseded by 'c' while 'a' ran, so it never runs.
expect(runs).toEqual(['a', 'c']);
gates[1].resolve();
await Promise.resolve();
expect(runs).toEqual(['a', 'c']);
});
it('should drop a pending value on clear', async () => {
const { runner, runs, gates } = createGatedRunner();
runner.submit('a');
runner.submit('b');
runner.clear();
gates[0].resolve();
await Promise.resolve();
await Promise.resolve();
expect(runs).toEqual(['a']);
});
it('should resolve waiters and keep draining after a run rejects', async () => {
const { runner, runs, gates } = createGatedRunner();
const a = runner.submit('a');
runner.submit('b');
gates[0].reject(new Error('boom'));
await expect(a).resolves.toBeUndefined();
// The rejected run does not strand the value queued behind it.
expect(runs).toEqual(['a', 'b']);
});
});
+30
View File
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as go2rtcAudio from '../../src/camera-manager/utils/go2rtc/audio';
import {
getResolvedLiveProvider,
isGo2RTCLiveProvider,
liveProviderSupports2WayAudio,
} from '../../src/utils/live-provider';
import { createCameraConfig, createHASS } from '../test-utils';
@@ -66,6 +67,20 @@ describe('live-provider utils', () => {
});
});
describe('isGo2RTCLiveProvider', () => {
it('should return true for go2rtc', () => {
expect(isGo2RTCLiveProvider('go2rtc')).toBe(true);
});
it('should return true for go2rtc-experimental', () => {
expect(isGo2RTCLiveProvider('go2rtc-experimental')).toBe(true);
});
it('should return false for other providers', () => {
expect(isGo2RTCLiveProvider('ha')).toBe(false);
});
});
describe('liveProviderSupports2WayAudio', () => {
it('should return false if resolved provider is not go2rtc', async () => {
const config = createCameraConfig({
@@ -101,6 +116,21 @@ describe('live-provider utils', () => {
);
});
it('should support 2-way audio for the experimental provider', async () => {
const config = createCameraConfig({
live_provider: 'go2rtc-experimental',
});
const hass = createHASS();
vi.mocked(go2rtcAudio.supports2WayAudio).mockResolvedValue(true);
const result = await liveProviderSupports2WayAudio(
hass,
config,
config.go2rtc.metadata_fetch_timeout_seconds,
);
expect(result).toBe(true);
});
it('should pass metadata fetch timeout through to go2rtc detection', async () => {
const config = createCameraConfig({
live_provider: 'go2rtc',
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { convertToWebSocketURL } from '../../src/utils/websocket-url';
// @vitest-environment jsdom
describe('convertToWebSocketURL', () => {
it('should convert http to ws', () => {
expect(convertToWebSocketURL('http://host:1984/api/ws?src=camera')).toBe(
'ws://host:1984/api/ws?src=camera',
);
});
it('should convert https to wss', () => {
expect(convertToWebSocketURL('https://host/api/ws?src=camera')).toBe(
'wss://host/api/ws?src=camera',
);
});
it('should convert a mixed-case scheme', () => {
expect(convertToWebSocketURL('HtTp://host/api/ws')).toBe('ws://host/api/ws');
});
it('should prepend the provided origin to a relative path', () => {
expect(convertToWebSocketURL('/api/ws?src=camera', 'https://ha:8123')).toBe(
'wss://ha:8123/api/ws?src=camera',
);
});
it('should prepend the location origin to a relative path by default', () => {
expect(convertToWebSocketURL('/api/ws?src=camera')).toBe(
'ws://localhost:3000/api/ws?src=camera',
);
});
it('should leave other schemes untouched', () => {
expect(convertToWebSocketURL('ws://host/api/ws')).toBe('ws://host/api/ws');
});
});