feat: Enable go2rtc live stream proxying (#2159)

- Closes #1984
This commit is contained in:
Dermot Duffy
2025-08-28 21:19:19 -07:00
committed by GitHub
parent c3d95a5b32
commit bc5022ef1a
29 changed files with 479 additions and 282 deletions
+58 -18
View File
@@ -4,7 +4,6 @@ import { Camera } from '../../src/camera-manager/camera.js';
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic.js';
import { CameraProxyConfig } from '../../src/camera-manager/types.js';
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
import { ProxyConfig } from '../../src/config/schema/cameras.js';
import {
callStateWatcherCallback,
createCameraConfig,
@@ -179,6 +178,7 @@ describe('Camera', () => {
{},
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
@@ -186,9 +186,10 @@ describe('Camera', () => {
],
[
'when media set to true',
{ media: true },
{ proxy: { media: true } },
{
dynamic: true,
live: false,
media: true,
ssl_verification: true,
ssl_ciphers: 'default' as const,
@@ -196,9 +197,10 @@ describe('Camera', () => {
],
[
'when media set to false',
{ media: false as const },
{ proxy: { media: false as const } },
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
@@ -206,9 +208,10 @@ describe('Camera', () => {
],
[
'when media set to auto',
{ media: 'auto' as const },
{ proxy: { media: 'auto' as const } },
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
@@ -216,9 +219,10 @@ describe('Camera', () => {
],
[
'when ssl_verification is set to auto',
{ ssl_verification: 'auto' as const },
{ proxy: { ssl_verification: 'auto' as const } },
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
@@ -226,9 +230,10 @@ describe('Camera', () => {
],
[
'when ssl_verification is set to true',
{ ssl_verification: true },
{ proxy: { ssl_verification: true } },
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
@@ -236,9 +241,10 @@ describe('Camera', () => {
],
[
'when ssl_verification is set to false',
{ ssl_verification: false },
{ proxy: { ssl_verification: false } },
{
dynamic: true,
live: false,
media: false,
ssl_verification: false,
ssl_ciphers: 'default' as const,
@@ -246,9 +252,10 @@ describe('Camera', () => {
],
[
'when ssl_ciphers is set to auto',
{ ssl_ciphers: 'auto' as const },
{ proxy: { ssl_ciphers: 'auto' as const } },
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
@@ -256,9 +263,10 @@ describe('Camera', () => {
],
[
'when ssl_ciphers is set to modern',
{ ssl_ciphers: 'modern' as const },
{ proxy: { ssl_ciphers: 'modern' as const } },
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'modern' as const,
@@ -266,9 +274,47 @@ describe('Camera', () => {
],
[
'when dynamic is set to false',
{ dynamic: false },
{ proxy: { dynamic: false } },
{
dynamic: false,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
[
'when go2rtc has no url',
{ live_provider: 'go2rtc' },
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
[
'when go2rtc has a url',
{ live_provider: 'go2rtc', go2rtc: { url: 'http://localhost:1984' } },
{
dynamic: true,
live: true,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
},
],
[
'when go2rtc has a url but live is set to false',
{
live_provider: 'go2rtc',
go2rtc: { url: 'http://localhost:1984' },
proxy: { live: false },
},
{
dynamic: true,
live: false,
media: false,
ssl_verification: true,
ssl_ciphers: 'default' as const,
@@ -276,15 +322,9 @@ describe('Camera', () => {
],
])(
'%s',
(
_name: string,
proxyConfig: Partial<ProxyConfig>,
expectedResult: CameraProxyConfig,
) => {
(_name: string, cameraConfig: unknown, expectedResult: CameraProxyConfig) => {
const camera = new Camera(
createCameraConfig({
proxy: proxyConfig,
}),
createCameraConfig(cameraConfig),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
);
expect(camera.getProxyConfig()).toEqual(expectedResult);
+58 -18
View File
@@ -5,7 +5,6 @@ import { ReolinkCamera } from '../../../src/camera-manager/reolink/camera';
import { CameraProxyConfig } from '../../../src/camera-manager/types';
import { ActionsExecutor } from '../../../src/card-controller/actions/types';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import { ProxyConfig } from '../../../src/config/schema/cameras';
import { EntityRegistryManagerLive } from '../../../src/ha/registry/entity';
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
import {
@@ -341,6 +340,7 @@ describe('ReolinkCamera', () => {
{},
{
dynamic: true,
live: false,
media: true,
ssl_verification: false,
ssl_ciphers: 'intermediate' as const,
@@ -348,9 +348,10 @@ describe('ReolinkCamera', () => {
],
[
'when media set to on',
{ media: true },
{ proxy: { media: true } },
{
dynamic: true,
live: false,
media: true,
ssl_verification: false,
ssl_ciphers: 'intermediate' as const,
@@ -358,9 +359,10 @@ describe('ReolinkCamera', () => {
],
[
'when media set to off',
{ media: false },
{ proxy: { media: false } },
{
dynamic: true,
live: false,
media: false,
ssl_verification: false,
ssl_ciphers: 'intermediate' as const,
@@ -368,9 +370,10 @@ describe('ReolinkCamera', () => {
],
[
'when media set to auto',
{ media: 'auto' as const },
{ proxy: { media: 'auto' as const } },
{
dynamic: true,
live: false,
media: true,
ssl_verification: false,
ssl_ciphers: 'intermediate' as const,
@@ -378,9 +381,10 @@ describe('ReolinkCamera', () => {
],
[
'when ssl_verification is set to auto',
{ ssl_verification: 'auto' as const },
{ proxy: { ssl_verification: 'auto' as const } },
{
dynamic: true,
live: false,
media: true,
ssl_verification: false,
ssl_ciphers: 'intermediate' as const,
@@ -388,9 +392,10 @@ describe('ReolinkCamera', () => {
],
[
'when ssl_verification is set to true',
{ ssl_verification: true },
{ proxy: { ssl_verification: true } },
{
dynamic: true,
live: false,
media: true,
ssl_verification: true,
ssl_ciphers: 'intermediate' as const,
@@ -398,9 +403,10 @@ describe('ReolinkCamera', () => {
],
[
'when ssl_verification is set to false',
{ ssl_verification: false },
{ proxy: { ssl_verification: false } },
{
dynamic: true,
live: false,
media: true,
ssl_verification: false,
ssl_ciphers: 'intermediate' as const,
@@ -408,9 +414,10 @@ describe('ReolinkCamera', () => {
],
[
'when ssl_ciphers is set to auto',
{ ssl_ciphers: 'auto' as const },
{ proxy: { ssl_ciphers: 'auto' as const } },
{
dynamic: true,
live: false,
media: true,
ssl_verification: false,
ssl_ciphers: 'intermediate' as const,
@@ -418,9 +425,10 @@ describe('ReolinkCamera', () => {
],
[
'when ssl_ciphers is set to modern',
{ ssl_ciphers: 'modern' as const },
{ proxy: { ssl_ciphers: 'modern' as const } },
{
dynamic: true,
live: false,
media: true,
ssl_verification: false,
ssl_ciphers: 'modern' as const,
@@ -428,9 +436,47 @@ describe('ReolinkCamera', () => {
],
[
'when dynamic is set to false',
{ dynamic: false },
{ proxy: { dynamic: false } },
{
dynamic: false,
live: false,
media: true,
ssl_verification: false,
ssl_ciphers: 'intermediate' as const,
},
],
[
'when go2rtc has no url',
{ live_provider: 'go2rtc' },
{
dynamic: true,
live: false,
media: true,
ssl_verification: false,
ssl_ciphers: 'intermediate' as const,
},
],
[
'when go2rtc has a url',
{ live_provider: 'go2rtc', go2rtc: { url: 'http://localhost:1984' } },
{
dynamic: true,
live: true,
media: true,
ssl_verification: false,
ssl_ciphers: 'intermediate' as const,
},
],
[
'when go2rtc has a url but live is set to false',
{
live_provider: 'go2rtc',
go2rtc: { url: 'http://localhost:1984' },
proxy: { live: false },
},
{
dynamic: true,
live: false,
media: true,
ssl_verification: false,
ssl_ciphers: 'intermediate' as const,
@@ -438,15 +484,9 @@ describe('ReolinkCamera', () => {
],
])(
'%s',
(
_name: string,
proxyConfig: Partial<ProxyConfig>,
expectedResult: CameraProxyConfig,
) => {
(_name: string, cameraConfig: unknown, expectedResult: CameraProxyConfig) => {
const camera = new ReolinkCamera(
createCameraConfig({
proxy: proxyConfig,
}),
createCameraConfig(cameraConfig),
mock<CameraManagerEngine>(),
);
expect(camera.getProxyConfig()).toEqual(expectedResult);
+1
View File
@@ -40,6 +40,7 @@ describe('config defaults', () => {
},
proxy: {
dynamic: true,
live: 'auto',
media: 'auto',
ssl_verification: 'auto',
ssl_ciphers: 'auto',
+36 -5
View File
@@ -8,15 +8,21 @@ import {
import { createHASS } from '../test-utils.js';
describe('getWebProxiedURL', () => {
it('should return proxied URL with v != 0', () => {
expect(getWebProxiedURL('http://example.com', 2)).toBe(
it('should return proxied URL with default version', () => {
expect(getWebProxiedURL('http://example.com')).toBe(
'/api/hass_web_proxy/v0/?url=http%3A%2F%2Fexample.com',
);
});
it('should return proxied URL with non-default version', () => {
expect(getWebProxiedURL('http://example.com', { version: 2 })).toBe(
'/api/hass_web_proxy/v2/?url=http%3A%2F%2Fexample.com',
);
});
it('should return proxied URL with default v', () => {
expect(getWebProxiedURL('http://example.com')).toBe(
'/api/hass_web_proxy/v0/?url=http%3A%2F%2Fexample.com',
it('should return proxied URL with websocket', () => {
expect(getWebProxiedURL('http://example.com', { websocket: true })).toBe(
'/api/hass_web_proxy/v0/ws?url=http%3A%2F%2Fexample.com',
);
});
});
@@ -26,6 +32,7 @@ describe('shouldUseWebProxy', () => {
config: Partial<CameraProxyConfig> = {},
): CameraProxyConfig => ({
media: true,
live: true,
ssl_verification: true,
ssl_ciphers: 'default',
dynamic: true,
@@ -83,4 +90,28 @@ describe('addDynamicProxyURL', () => {
},
);
});
it('should add dynamic proxy URL using config defaults', async () => {
const hass = createHASS();
const proxyConfig: CameraProxyConfig = {
media: true,
live: true,
ssl_verification: false,
ssl_ciphers: 'insecure',
dynamic: true,
};
await addDynamicProxyURL(hass, 'http://example.com', {
proxyConfig: proxyConfig,
});
expect(hass.callService).toHaveBeenCalledWith(
'hass_web_proxy',
'create_proxied_url',
expect.objectContaining({
ssl_verification: false,
ssl_ciphers: 'insecure',
}),
);
});
});
+21
View File
@@ -7,6 +7,7 @@ import {
arrayMove,
aspectRatioToStyle,
contentsChanged,
convertHTTPAdressToWebsocket,
dayToDate,
desparsifyArrays,
errorToConsole,
@@ -514,3 +515,23 @@ describe('generateFloatApproximatelyEqualsCustomizer', () => {
});
});
});
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');
});
});
-60
View File
@@ -1,60 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { homeAssistantSignPath } from '../../src/ha/sign-path.js';
import { convertEndpointAddressToSignedWebsocket } from '../../src/utils/endpoint';
import { createHASS } from '../test-utils';
vi.mock('../../src/ha/sign-path.js');
describe('convertEndpointAddressToSignedWebsocket', () => {
it('without signing', async () => {
expect(
await convertEndpointAddressToSignedWebsocket(createHASS(), {
endpoint: 'http://example.com',
sign: false,
}),
).toBe('http://example.com');
});
describe('with signing', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('successful', async () => {
vi.mocked(homeAssistantSignPath).mockResolvedValue('http://signed.com');
expect(
await convertEndpointAddressToSignedWebsocket(createHASS(), {
endpoint: 'http://example.com',
sign: true,
}),
).toBe('ws://signed.com');
});
it('with null response', async () => {
vi.mocked(homeAssistantSignPath).mockResolvedValue(null);
expect(
await convertEndpointAddressToSignedWebsocket(createHASS(), {
endpoint: 'http://example.com',
sign: true,
}),
).toBeNull();
});
it('with exception on signing', async () => {
const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
vi.mocked(homeAssistantSignPath).mockRejectedValue(new Error());
expect(
await convertEndpointAddressToSignedWebsocket(createHASS(), {
endpoint: 'http://example.com',
sign: true,
}),
).toBeNull();
expect(consoleSpy).toBeCalled();
});
});
});