feat: Add proxying support for images (#2427)

- Closes #2418
This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 9384785d37
commit 1cd5520154
51 changed files with 2404 additions and 687 deletions
+32 -10
View File
@@ -2,6 +2,7 @@ import { ActionsExecutor } from '../card-controller/actions/types';
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
import { CameraConfig } from '../config/schema/cameras';
import { EnabledProxyConfig, resolveProxyConfig } from '../config/schema/common/proxy';
import { isTriggeredState } from '../ha/is-triggered-state';
import { HassStateDifference, HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize';
@@ -100,6 +101,8 @@ export class Camera {
}
protected async _has2WayAudioCapability(hass: HomeAssistant): Promise<boolean> {
// Check disable/disableExcept/force early to short-circuit the expensive
// network call to fetch go2rtc metadata.
if (this._config.capabilities?.disable?.includes('2-way-audio')) {
return false;
}
@@ -115,7 +118,7 @@ export class Camera {
this.getConfig(),
this.getConfig().go2rtc.metadata_fetch_timeout_seconds,
this._getGo2RTCMetadataEndpoint(),
this.getProxyConfig(),
this.getLiveProxyConfig(),
);
}
@@ -137,7 +140,7 @@ export class Camera {
}
public async destroy(): Promise<void> {
this._destroyCallbacks.forEach((callback) => callback());
await Promise.all(this._destroyCallbacks.map((callback) => callback()));
}
public getConfig(): CameraConfig {
@@ -204,6 +207,7 @@ export class Camera {
public getProxyConfig(): CameraProxyConfig {
return {
...resolveProxyConfig(this._config.proxy),
live:
this._config.proxy.live === 'auto'
? // Live is proxied if the live provider is go2rtc and if a go2rtc
@@ -211,13 +215,32 @@ export class Camera {
this._config.live_provider === 'go2rtc' && !!this._config.go2rtc?.url
: this._config.proxy.live,
media: this._config.proxy.media === 'auto' ? false : this._config.proxy.media,
};
}
dynamic: this._config.proxy.dynamic,
ssl_verification: this._config.proxy.ssl_verification !== false,
ssl_ciphers:
this._config.proxy.ssl_ciphers === 'auto'
? 'default'
: this._config.proxy.ssl_ciphers,
public getLiveProxyConfig(): EnabledProxyConfig {
const config = this.getProxyConfig();
return {
...config,
// `enabled` uses the resolved engine decision (so `auto` may become
// true), whereas `enforce` uses the raw user setting so only an explicit
// `true` means "fail instead of falling back" if the proxy is unavailable.
enabled: config.live,
enforce: this._config.proxy.live === true,
};
}
public getMediaProxyConfig(): EnabledProxyConfig {
const config = this.getProxyConfig();
return {
...config,
// `enabled` uses the resolved engine decision (so `auto` may become
// true), whereas `enforce` uses the raw user setting so only an explicit
// `true` means "fail instead of falling back" if the proxy is unavailable.
enabled: config.media,
enforce: this._config.proxy.media === true,
};
}
@@ -250,11 +273,10 @@ export class Camera {
this._destroyCallbacks.push(callback);
}
protected _subscribeBasedOnCapabilities(
private _subscribeBasedOnCapabilities(
stateWatcher: StateWatcherSubscriptionInterface,
): void {
if (this._capabilities?.has('trigger')) {
stateWatcher.unsubscribe(this._stateChangeHandler);
stateWatcher.subscribe(this._stateChangeHandler, this._config.triggers.entities);
}
}
+2 -5
View File
@@ -1,7 +1,7 @@
import { ExpiringEqualityCache } from '../cache/expiring-cache';
import { SSLCiphers } from '../config/schema/cameras';
import { AdvancedCameraCardView } from '../config/schema/common/const';
import { InternalIcon } from '../config/schema/common/icon';
import { ResolvedProxyConfig } from '../config/schema/common/proxy';
import { BaseQuery, QueryFilters, QuerySource } from '../query-source';
import { CapabilityKey, Endpoint } from '../types';
import { ViewMedia } from '../view/item';
@@ -137,12 +137,9 @@ export interface CameraEndpoints {
webrtcCard?: Endpoint;
}
export interface CameraProxyConfig {
dynamic: boolean;
export interface CameraProxyConfig extends ResolvedProxyConfig {
live: boolean;
media: boolean;
ssl_verification: boolean;
ssl_ciphers: SSLCiphers;
}
export interface EngineOptions {
+7 -4
View File
@@ -1,9 +1,9 @@
import { EnabledProxyConfig } from '../../../config/schema/common/proxy';
import { homeAssistantSignAndFetch } from '../../../ha/fetch';
import { HomeAssistant } from '../../../ha/types';
import { createProxiedEndpointIfNecessary } from '../../../ha/web-proxy';
import { Endpoint } from '../../../types';
import { errorToConsole } from '../../../utils/basic';
import { CameraProxyConfig } from '../../types';
import { Go2RTCStreamInfo, go2RTCStreamInfoSchema } from './types';
const getGo2RTCStreamMetadata = async (
@@ -44,14 +44,14 @@ const streamSupports2WayAudio = (streamInfo: Go2RTCStreamInfo | null): boolean =
*
* @param hass Home Assistant instance.
* @param go2rtcMetadataEndpoint The go2rtc metadata endpoint.
* @param proxyConfig The camera's proxy configuration for live streams.
* @param proxyConfig The resolved proxy configuration for live streams.
* @returns True if supports 2-way audio, false otherwise.
*/
export const supports2WayAudio = async (
hass: HomeAssistant,
metadataFetchTimeoutSeconds: number,
go2rtcMetadataEndpoint?: Endpoint | null,
proxyConfig?: CameraProxyConfig,
proxyConfig?: EnabledProxyConfig,
): Promise<boolean> => {
if (!go2rtcMetadataEndpoint) {
return false;
@@ -61,8 +61,11 @@ export const supports2WayAudio = async (
hass,
go2rtcMetadataEndpoint,
proxyConfig,
{ context: 'live', openLimit: 1 },
{ openLimit: 1 },
);
if (!endpoint) {
return false;
}
const streamInfo = await getGo2RTCStreamMetadata(
hass,