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:
+18
-14
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user