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,
+12 -15
View File
@@ -1,9 +1,9 @@
import { format } from 'date-fns';
import { homeAssistantGetSignedURLIfNecessary } from '../../ha/sign-path';
import { localize } from '../../localize/localize';
import { AdvancedCameraCardError } from '../../types';
import { errorToConsole } from '../../utils/basic';
import { downloadURL } from '../../utils/download';
import { homeAssistantSignPath } from '../../ha/sign-path';
import { ViewItem } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier';
import { ViewItemCapabilities } from '../../view/types';
@@ -90,22 +90,19 @@ export class ViewItemManager {
throw new AdvancedCameraCardError(localize('error.download_no_media'));
}
let finalURL = endpoint.endpoint;
if (endpoint.sign) {
let response: string | null | undefined;
try {
response = await homeAssistantSignPath(hass, endpoint.endpoint);
} catch (e) {
errorToConsole(e as Error);
}
if (!response) {
throw new AdvancedCameraCardError(localize('error.download_sign_failed'));
}
finalURL = response;
let url: string | null;
try {
url = await homeAssistantGetSignedURLIfNecessary(hass, endpoint);
} catch (e) {
errorToConsole(e as Error);
url = null;
}
downloadURL(finalURL, this._generateDownloadFilename(item));
if (!url) {
throw new AdvancedCameraCardError(localize('error.download_sign_failed'));
}
downloadURL(url, this._generateDownloadFilename(item));
}
private _generateDownloadFilename(item: ViewItem): string {
+34 -18
View File
@@ -2,40 +2,42 @@ import { ReactiveController, ReactiveControllerHost } from 'lit';
import { Timer } from '../utils/timer';
export class CachedValueController<T> implements ReactiveController {
private _value?: T;
private _host: ReactiveControllerHost;
private _timerSeconds: number;
private _host: ReactiveControllerHost & HTMLElement;
private _value: T | null = null;
private _timerSeconds: number | null = null;
private _callback: () => T;
private _getTimerSecondsCallback: () => number | null;
private _timerStartCallback?: () => void;
private _timerStopCallback?: () => void;
private _timerTickCallback?: () => void;
private _timer = new Timer();
constructor(
host: ReactiveControllerHost,
timerSeconds: number,
host: ReactiveControllerHost & HTMLElement,
getTimerSecondsCallback: () => number | null,
callback: () => T,
timerStartCallback?: () => void,
timerStopCallback?: () => void,
timerTickCallback?: () => void,
) {
this._timerSeconds = timerSeconds;
this._getTimerSecondsCallback = getTimerSecondsCallback;
this._timerSeconds = getTimerSecondsCallback();
this._callback = callback;
this._timerStartCallback = timerStartCallback;
this._timerStopCallback = timerStopCallback;
this._timerTickCallback = timerTickCallback;
(this._host = host).addController(this);
}
/**
* Remove the controller for the host.
*/
public removeController(): void {
this.stopTimer();
this._host.removeController(this);
}
/**
* Get the value.
*/
get value(): T | undefined {
public getValue(): T | null {
return this._value;
}
@@ -44,13 +46,14 @@ export class CachedValueController<T> implements ReactiveController {
*/
public updateValue(): void {
this._value = this._callback();
this._host.requestUpdate();
}
/**
* Clear the cached value.
*/
public clearValue(): void {
this._value = undefined;
this._value = null;
}
/**
@@ -69,10 +72,14 @@ export class CachedValueController<T> implements ReactiveController {
public startTimer(): void {
this.stopTimer();
if (!this._timerSeconds || this._timerSeconds <= 0) {
return;
}
this._timerStartCallback?.();
this._timer.startRepeated(this._timerSeconds, () => {
this._timerTickCallback?.();
this.updateValue();
this._host.requestUpdate();
});
}
@@ -80,13 +87,22 @@ export class CachedValueController<T> implements ReactiveController {
return this._timer.isRunning();
}
public hostUpdate(): void {
const newTimerSeconds = this._getTimerSecondsCallback();
if (newTimerSeconds !== this._timerSeconds) {
this._timerSeconds = newTimerSeconds;
if (this._host.isConnected) {
this.startTimer();
}
}
}
/**
* Host has connected to the cache.
*/
hostConnected(): void {
this.updateValue();
this.startTimer();
this._host.requestUpdate();
}
/**
@@ -55,7 +55,7 @@ export class UpdatingImageMediaPlayerController implements MediaPlayerController
public async getScreenshotURL(): Promise<string | null> {
await this._host.updateComplete;
return this._getCachedValueController()?.value ?? null;
return this._getCachedValueController()?.getValue() ?? null;
}
public getFullscreenElement(): FullscreenElement | null {
+232
View File
@@ -0,0 +1,232 @@
import { ReactiveController, ReactiveControllerHost } from 'lit';
import { isEqual } from 'lodash-es';
import { EnabledProxyConfig } from '../config/schema/common/proxy.js';
import { homeAssistantGetSignedURLIfNecessary } from '../ha/sign-path.js';
import { HomeAssistant } from '../ha/types.js';
import {
CreateProxiedEndpointOptions,
createProxiedEndpointIfNecessary,
} from '../ha/web-proxy.js';
import { Endpoint } from '../types.js';
import { errorToConsole } from '../utils/basic.js';
const PROXY_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
// Re-register and re-sign well before the signed URL expires.
const PROXY_CACHE_TTL_SECONDS = PROXY_URL_SIGN_EXPIRY_SECONDS / 2;
interface SignedURLControllerOptions {
// The endpoint to resolve. The `sign` flag on the endpoint controls whether
// the URL requires HA authentication even when proxying is disabled (e.g.
// HA-relative API paths like go2rtc streams served through Frigate).
endpoint?: Endpoint;
hass?: HomeAssistant;
proxyConfig?: EnabledProxyConfig | null;
proxyEndpointOptions?: CreateProxiedEndpointOptions;
}
type SignedURLErrorType = 'sign' | 'proxy';
export class SignedURLController implements ReactiveController {
private _host: ReactiveControllerHost;
private _getOptionsCallback: () => SignedURLControllerOptions;
private _valueChangeCallback: (() => void) | undefined;
private _value: string | null = null;
private _error: SignedURLErrorType | null = null;
private _cachedAt: Date | null = null;
// Caching and race-condition state.
// The targetURL and proxy config are tracked to detect when inputs change and
// invalidate the cache. The requestID tracks the most recent valid fetch, to
// ensure that older, slower in-flight requests do not overwrite newer ones.
private _targetURL: string | null = null;
private _targetProxyConfig: EnabledProxyConfig | null = null;
private _requestID = 0;
constructor(
host: ReactiveControllerHost,
getOptionsCallback: () => SignedURLControllerOptions,
valueChangeCallback?: () => void,
) {
(this._host = host).addController(this);
this._getOptionsCallback = getOptionsCallback;
this._valueChangeCallback = valueChangeCallback;
}
public getError(): SignedURLErrorType | null {
return this._error;
}
public getValue(): string | null {
const options = this._getOptionsCallback();
// When the endpoint requires signing or proxying, the URL must go through
// the async resolution path. For proxied URLs, under no circumstances
// should we fall back to returning the unproxied URL — doing so risks
// leaking traffic or causing mixed-content errors.
if (options.proxyConfig?.enabled || options.endpoint?.sign) {
return this._value;
}
return options.endpoint?.endpoint ?? null;
}
public hostDisconnected(): void {
++this._requestID;
this._value = null;
this._error = null;
this._cachedAt = null;
this._targetURL = null;
this._targetProxyConfig = null;
}
public async hostUpdate(): Promise<void> {
const { hass, endpoint, proxyConfig, proxyEndpointOptions } =
this._getOptionsCallback();
if (!hass || !endpoint || (!proxyConfig?.enabled && !endpoint.sign)) {
// Invalidate any in-flight async work so a stale proxy/sign result cannot
// repopulate the controller after inputs have been cleared or disabled.
++this._requestID;
this._value = null;
this._error = null;
this._targetURL = null;
this._targetProxyConfig = null;
this._cachedAt = null;
return;
}
const targetURL = new URL(endpoint.endpoint, document.baseURI).toString();
// Pick only the EnabledProxyConfig fields so that extraneous properties
// (e.g. `live`/`media` from CameraProxyConfig spreads) don't cause
// spurious cache invalidations. When only signing (no proxy), the config
// is null.
const comparableConfig: EnabledProxyConfig | null = proxyConfig?.enabled
? {
dynamic: proxyConfig.dynamic,
ssl_verification: proxyConfig.ssl_verification,
ssl_ciphers: proxyConfig.ssl_ciphers,
enabled: proxyConfig.enabled,
enforce: proxyConfig.enforce,
}
: null;
if (
targetURL !== this._targetURL ||
!isEqual(comparableConfig, this._targetProxyConfig)
) {
this._targetURL = targetURL;
this._targetProxyConfig = comparableConfig;
this._cachedAt = null;
this._error = null;
this._value = null;
} else if (
this._cachedAt &&
new Date().getTime() - this._cachedAt.getTime() < PROXY_CACHE_TTL_SECONDS * 1000
) {
return;
} else if (!this._cachedAt) {
// Either async work for these exact inputs is already in flight, or
// we already failed for these exact inputs. Either way, don't
// restart: inputs must change before we retry.
return;
}
// Mark as in-flight so the `!this._cachedAt` guard above prevents
// subsequent hostUpdate() calls from restarting the async work.
this._cachedAt = null;
const requestID = ++this._requestID;
const resolvedEndpoint = await this._proxy(
hass,
targetURL,
endpoint,
proxyConfig,
proxyEndpointOptions,
);
if (this._isStale(requestID)) {
return;
}
if (!resolvedEndpoint) {
this._applyError('proxy');
return;
}
const signedURL = await this._sign(hass, resolvedEndpoint);
if (this._isStale(requestID)) {
return;
}
if (!signedURL) {
this._applyError('sign');
return;
}
this._applySuccess(signedURL);
}
/**
* Proxy the endpoint if proxying is enabled, otherwise return it as-is.
*/
private async _proxy(
hass: HomeAssistant,
targetURL: string,
endpoint: Endpoint,
proxyConfig: EnabledProxyConfig | null | undefined,
proxyEndpointOptions: CreateProxiedEndpointOptions | undefined,
): Promise<Endpoint | null> {
if (!proxyConfig?.enabled) {
return { endpoint: targetURL, sign: endpoint.sign };
}
try {
return await createProxiedEndpointIfNecessary(
hass,
{ endpoint: targetURL, sign: false },
proxyConfig,
{
ttl: PROXY_URL_SIGN_EXPIRY_SECONDS,
openLimit: 0,
...proxyEndpointOptions,
},
);
} catch (e: unknown) {
errorToConsole(e as Error);
return null;
}
}
/**
* Sign the endpoint if it requires signing, otherwise return the URL as-is.
*/
private async _sign(hass: HomeAssistant, endpoint: Endpoint): Promise<string | null> {
try {
return await homeAssistantGetSignedURLIfNecessary(
hass,
endpoint,
PROXY_URL_SIGN_EXPIRY_SECONDS,
);
} catch (e: unknown) {
errorToConsole(e as Error);
return null;
}
}
private _isStale(requestID: number): boolean {
return this._requestID !== requestID;
}
private _applySuccess(url: string): void {
this._value = url;
this._error = null;
this._valueChangeCallback?.();
this._cachedAt = new Date();
this._host.requestUpdate();
}
private _applyError(error: SignedURLErrorType): void {
this._value = null;
this._error = error;
this._host.requestUpdate();
}
}
+97 -47
View File
@@ -11,12 +11,13 @@ import { customElement, property, state } from 'lit/decorators.js';
import { live } from 'lit/directives/live.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { isEqual } from 'lodash-es';
import { CameraManager } from '../camera-manager/manager.js';
import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js';
import { CachedValueController } from '../components-lib/cached-value-controller.js';
import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
import { SignedURLController } from '../components-lib/signed-url-controller.js';
import { CameraConfig } from '../config/schema/cameras.js';
import { type ImageBaseConfig, ImageMode } from '../config/schema/common/image.js';
import { EnabledProxyConfig } from '../config/schema/common/proxy.js';
import { isHassDifferent } from '../ha/is-hass-different.js';
import { HomeAssistant } from '../ha/types.js';
import defaultImage from '../images/iris-screensaver.jpg';
@@ -79,8 +80,8 @@ export class AdvancedCameraCardImageUpdatingPlayer
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false, hasChanged: contentsChanged })
public proxyConfig?: EnabledProxyConfig;
// Using contentsChanged to ensure overridden configs (e.g. when the
// 'show_image_during_load' option is true for live views, an overridden
@@ -89,11 +90,36 @@ export class AdvancedCameraCardImageUpdatingPlayer
public imageConfig?: ImageBaseConfig;
@state()
private _message: Message | null = null;
private _imageLoadError = false;
private _refImage: Ref<HTMLImageElement> = createRef();
private _cachedValueController?: CachedValueController<string>;
private _cachedValueController = new CachedValueController(
this,
() => this.imageConfig?.refresh_seconds ?? null,
() => this._getImageSource(),
() => dispatchMediaPlayEvent(this),
() => dispatchMediaPauseEvent(this),
// Clear image load errors on each timer tick so the next render retries the
// <img>. Retries are bounded by refresh_seconds, not a tight loop.
() => {
this._imageLoadError = false;
},
);
private _signedURLController = new SignedURLController(
this,
() => ({
hass: this.hass,
endpoint: this.imageConfig?.url ? { endpoint: this.imageConfig.url } : undefined,
proxyConfig: this.proxyConfig,
}),
() => {
this._cachedValueController.clearValue();
this._imageLoadError = false;
},
);
private _boundVisibilityHandler = this._visibilityHandler.bind(this);
private _mediaLoadedInfo: MediaLoadedInfo | null = null;
@@ -101,7 +127,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
private _mediaPlayerController = new UpdatingImageMediaPlayerController(
this,
() => this._refImage.value ?? null,
() => this._cachedValueController ?? null,
() => this._cachedValueController,
);
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
@@ -143,21 +169,6 @@ export class AdvancedCameraCardImageUpdatingPlayer
* @param _changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('imageConfig')) {
if (this._cachedValueController) {
this._cachedValueController.removeController();
}
if (this.imageConfig) {
this._cachedValueController = new CachedValueController(
this,
this.imageConfig.refresh_seconds,
this._getImageSource.bind(this),
() => dispatchMediaPlayEvent(this),
() => dispatchMediaPauseEvent(this),
);
}
}
const relevantEntity = this._getRelevantEntityForMode(
resolveImageMode({
imageConfig: this.imageConfig,
@@ -170,20 +181,19 @@ export class AdvancedCameraCardImageUpdatingPlayer
// the state is not acceptable, discard the old value (to allow a stock or
// backup image to be displayed).
if (
changedProps.has('imageConfig') ||
changedProps.has('cameraConfig') ||
changedProps.has('proxyConfig') ||
changedProps.has('view') ||
(relevantEntity && !this._getAcceptableState(relevantEntity))
) {
this._cachedValueController?.clearValue();
this._imageLoadError = false;
}
if (!this._cachedValueController?.value) {
if (!this._cachedValueController?.getValue()) {
this._cachedValueController?.updateValue();
}
if (['imageConfig', 'view'].some((prop) => changedProps.has(prop))) {
this._message = null;
}
}
/**
@@ -220,7 +230,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
*/
disconnectedCallback(): void {
this._cachedValueController?.stopTimer();
this._message = null;
this._imageLoadError = false;
document.removeEventListener('visibilitychange', this._boundVisibilityHandler);
super.disconnectedCallback();
}
@@ -254,12 +264,23 @@ export class AdvancedCameraCardImageUpdatingPlayer
}
/**
* Build a working absolute image URL that the browser will not cache.
* @param url An input URL (may be relative to document origin)
* @returns A new URL as a string (absolute, will not be browser cached).
* Build an image URL that the browser will not cache. Supports two modes:
* - 'query-string': Appends a `_t` parameter. This is the most robust way to
* defeat caching (it bypasses HTTP caches) but it changes the path sent to
* the server and so can invalidate signed URLs.
* - 'fragment': Appends a `_t` fragment. This is less robust (the browser
* might still serve from its HTTP cache) but it does not change the URL
* sent to the server so it is safe for signed URLs.
* @param url The input URL.
* @param mode The cache-busting mode.
* @returns The cache-busted URL string.
*/
private _buildImageURL(url: URL): string {
url.searchParams.append('_t', String(Date.now()));
private _buildCacheBustURL(url: URL, mode: 'query-string' | 'fragment'): string {
if (mode === 'query-string') {
url.searchParams.append('_t', String(Date.now()));
} else {
url.hash = `_t=${Date.now()}`;
}
return url.toString();
}
@@ -294,7 +315,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
if (state?.attributes.entity_picture) {
const urlObj = new URL(state.attributes.entity_picture, document.baseURI);
this._addQueryParametersToURL(urlObj, this.imageConfig?.entity_parameters);
return this._buildImageURL(urlObj);
return this._buildCacheBustURL(urlObj, 'query-string');
}
}
@@ -303,12 +324,21 @@ export class AdvancedCameraCardImageUpdatingPlayer
if (state?.attributes.entity_picture) {
const urlObj = new URL(state.attributes.entity_picture, document.baseURI);
this._addQueryParametersToURL(urlObj, this.imageConfig?.entity_parameters);
return this._buildImageURL(urlObj);
return this._buildCacheBustURL(urlObj, 'query-string');
}
}
if (mode === 'url' && this.imageConfig?.url) {
return this._buildImageURL(new URL(this.imageConfig.url, document.baseURI));
const url = this._signedURLController.getValue();
if (url) {
const urlObj = new URL(url, document.baseURI);
if (this.proxyConfig?.enabled) {
// Use a fragment for cache-busting proxied URLs, as this does not
// change the path and thus preserves the validity of the signed URL.
return this._buildCacheBustURL(urlObj, 'fragment');
}
return this._buildCacheBustURL(urlObj, 'query-string');
}
}
return defaultImage;
@@ -319,17 +349,43 @@ export class AdvancedCameraCardImageUpdatingPlayer
*/
private _forceSafeImage(stockOnly?: boolean): void {
if (this._refImage.value) {
this._refImage.value.src =
!stockOnly && this.imageConfig?.url ? this.imageConfig.url : defaultImage;
// Avoid restoring the raw configured URL when proxying is enabled, since
// that would bypass the proxied/signed URL path on visibility changes.
const configuredURL =
!stockOnly && !this.proxyConfig?.enabled ? this.imageConfig?.url ?? null : null;
this._refImage.value.src = configuredURL ?? defaultImage;
}
}
private _getDisplayMessage(): Message | null {
const error = this._signedURLController.getError();
if (error) {
return {
type: 'error',
message: localize(
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
),
context: this.proxyConfig,
};
}
if (this._imageLoadError) {
return {
type: 'error',
message: localize('error.image_load_error'),
context: this.imageConfig,
};
}
return null;
}
protected render(): TemplateResult | void {
if (this._message) {
return renderMessage(this._message);
const message = this._getDisplayMessage();
if (message) {
return renderMessage(message);
}
const src = this._cachedValueController?.value;
const src = this._cachedValueController?.getValue();
// Note the use of live() below to ensure the update will restore the image
// src if it's been changed via _forceSafeImage().
return src
@@ -364,13 +420,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
// failed to load.
this._forceSafeImage(true);
} else if (mode === 'url') {
// In url mode, the user likely specified a URL that cannot be
// resolved. Show an error message.
this._message = {
type: 'error',
message: localize('error.image_load_error'),
context: this.imageConfig,
};
this._imageLoadError = true;
}
}}
/>
+16 -1
View File
@@ -7,7 +7,11 @@ import { ViewManagerEpoch } from '../card-controller/view/types';
import { ZoomSettingsObserved } from '../components-lib/zoom/types';
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context';
import { CameraConfig } from '../config/schema/cameras';
import { ImageViewConfig } from '../config/schema/image';
import {
type EnabledProxyConfig,
resolveProxyConfig,
} from '../config/schema/common/proxy';
import { ImageViewConfig, type ImageViewProxyConfig } from '../config/schema/image';
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const';
import { HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize.js';
@@ -82,6 +86,16 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
: intermediateTemplate}`;
}
private _resolveProxyConfig(proxy?: ImageViewProxyConfig): EnabledProxyConfig | null {
return proxy
? {
...resolveProxyConfig(proxy),
enabled: proxy.enabled,
enforce: proxy.enabled,
}
: null;
}
protected render(): TemplateResult | void {
if (!this.hass) {
return;
@@ -108,6 +122,7 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
.view=${this.viewManagerEpoch?.manager.getView()}
.imageConfig=${this.imageConfig}
.cameraConfig=${this.cameraConfig}
.proxyConfig=${this._resolveProxyConfig(this.imageConfig?.proxy) ?? undefined}
>
</advanced-camera-card-image-updating-player>
`);
+1
View File
@@ -302,6 +302,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
${ref(this._refProvider)}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
.proxyConfig=${this.camera.getLiveProxyConfig()}
class=${classMap({
...classes,
// The image provider is providing the temporary loading image,
+52 -98
View File
@@ -6,35 +6,26 @@ import {
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { customElement, property } from 'lit/decorators.js';
import { Camera } from '../../../../camera-manager/camera.js';
import { CameraEndpoints } from '../../../../camera-manager/types.js';
import { MicrophoneState } from '../../../../card-controller/types.js';
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
import { SignedURLController } from '../../../../components-lib/signed-url-controller.js';
import { MicrophoneConfig } from '../../../../config/schema/live.js';
import { homeAssistantSignPath } from '../../../../ha/sign-path.js';
import { HomeAssistant } from '../../../../ha/types.js';
import { createProxiedEndpointIfNecessary } from '../../../../ha/web-proxy.js';
import { localize } from '../../../../localize/localize.js';
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
import { MediaPlayer, MediaPlayerController, Message } from '../../../../types.js';
import { errorToConsole } from '../../../../utils/basic.js';
import { MediaPlayer, MediaPlayerController } from '../../../../types.js';
import { renderMessage } from '../../../message.js';
import { VideoRTC } from './video-rtc.js';
customElements.define('advanced-camera-card-live-go2rtc-player', VideoRTC);
// Note (2023-02-18): Depending on the behavior of the player / browser is
// possible this URL will need to be re-signed in order to avoid HA spamming
// logs after the expiry time, but this complexity is not added for now until
// there are verified cases of this being an issue (see equivalent in the JSMPEG
// provider).
const GO2RTC_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
@customElement('advanced-camera-card-live-go2rtc')
export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer {
// Not an reactive property to avoid resetting the video.
// Not a reactive property to avoid resetting the video.
public hass?: HomeAssistant;
@property({ attribute: false })
@@ -52,10 +43,8 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
@property({ attribute: true, type: Boolean })
public controls = false;
@state()
private _message: Message | null = null;
private _player?: VideoRTC;
private _hasLiveError = false;
private _mediaPlayerController = new VideoMediaPlayerController(
this,
@@ -63,13 +52,29 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
() => this.controls,
);
private _signedURLController = new SignedURLController(
this,
() => {
const endpoint = this.cameraEndpoints?.go2rtc;
if (!this.hass || !endpoint) {
return {};
}
return {
hass: this.hass,
endpoint,
proxyConfig: this.camera?.getLiveProxyConfig(),
proxyEndpointOptions: { websocket: true },
};
},
() => this._createPlayer(),
);
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._mediaPlayerController;
}
disconnectedCallback(): void {
this._player = undefined;
this._message = null;
super.disconnectedCallback();
}
@@ -81,82 +86,8 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
this.requestUpdate();
}
private _handleError(message: Message, e?: Error): void {
if (e) {
errorToConsole(e as Error);
}
this._message = {
type: 'error',
...message,
};
dispatchLiveErrorEvent(this);
return;
}
private async _getPlayerSource(): Promise<string | null> {
const cameraConfig = this.camera?.getConfig();
const proxyConfig = this.camera?.getProxyConfig();
if (!this.hass || !cameraConfig) {
return null;
}
const streamEndpoint = this.cameraEndpoints?.go2rtc;
if (!streamEndpoint) {
this._handleError({
message: localize('error.live_camera_no_endpoint'),
context: cameraConfig,
});
return null;
}
let result: string | null = null;
try {
const endpoint = await createProxiedEndpointIfNecessary(
this.hass,
streamEndpoint,
proxyConfig,
{
context: 'live',
ttl: GO2RTC_URL_SIGN_EXPIRY_SECONDS,
websocket: true,
// The link may need to be opened multiple times.
openLimit: 0,
},
);
if (endpoint.sign) {
result = await homeAssistantSignPath(
this.hass,
endpoint.endpoint,
GO2RTC_URL_SIGN_EXPIRY_SECONDS,
);
if (!result) {
this._handleError({
message: localize('error.failed_sign'),
context: cameraConfig,
});
}
} else {
result = endpoint.endpoint;
}
} catch (e) {
this._handleError(
{
message: localize('error.failed_proxy'),
context: cameraConfig,
},
e as Error,
);
}
return result;
}
private async _createPlayer(): Promise<void> {
const src = await this._getPlayerSource();
private _createPlayer(): void {
const src = this._signedURLController.getValue();
if (!src) {
return;
}
@@ -178,12 +109,20 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('cameraEndpoints')) {
this._message = null;
// Clear old player; the new one is created by the
// SignedURLController's valueChangeCallback once the URL resolves.
this._player = undefined;
}
if (!this._message && (!this._player || changedProps.has('cameraEndpoints'))) {
this._createPlayer();
// Only treat a missing go2rtc endpoint as an error after cameraEndpoints
// has been explicitly set (not undefined / still loading).
const hasError =
!!this._signedURLController.getError() ||
(!!this.cameraEndpoints && !this.cameraEndpoints.go2rtc);
if (hasError && !this._hasLiveError) {
dispatchLiveErrorEvent(this);
}
this._hasLiveError = hasError;
if (changedProps.has('controls') && this._player) {
this._player.setControls(this.controls);
@@ -203,8 +142,22 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
}
protected render(): TemplateResult | void {
if (this._message) {
return renderMessage(this._message);
const error = this._signedURLController.getError();
if (error) {
return renderMessage({
type: 'error',
message: localize(
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
),
context: this.camera?.getConfig(),
});
}
if (!this.cameraEndpoints?.go2rtc) {
return renderMessage({
type: 'error',
message: localize('error.live_camera_no_endpoint'),
context: this.camera?.getConfig(),
});
}
return html`${this._player}`;
}
@@ -216,6 +169,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-live-go2rtc-player': VideoRTC;
'advanced-camera-card-live-go2rtc': AdvancedCameraCardGo2RTC;
}
}
+5
View File
@@ -2,6 +2,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { CameraConfig } from '../../../config/schema/cameras';
import { EnabledProxyConfig } from '../../../config/schema/common/proxy';
import { HomeAssistant } from '../../../ha/types';
import basicBlockStyle from '../../../scss/basic-block.scss';
import {
@@ -19,6 +20,9 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@property({ attribute: false })
public proxyConfig?: EnabledProxyConfig;
private _refImage: Ref<MediaPlayerElement> = createRef();
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
@@ -37,6 +41,7 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
.hass=${this.hass}
.imageConfig=${this.cameraConfig.image}
.cameraConfig=${this.cameraConfig}
.proxyConfig=${this.proxyConfig}
>
</advanced-camera-card-image-updating-player>
`;
+3 -3
View File
@@ -14,7 +14,7 @@ import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispa
import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js';
import { CameraConfig } from '../../../config/schema/cameras.js';
import { CardWideConfig } from '../../../config/schema/types.js';
import { homeAssistantSignPath } from '../../../ha/sign-path.js';
import { homeAssistantGetSignedURLIfNecessary } from '../../../ha/sign-path.js';
import { HomeAssistant } from '../../../ha/types.js';
import { localize } from '../../../localize/localize.js';
import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss';
@@ -186,9 +186,9 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
let response: string | null | undefined;
try {
response = await homeAssistantSignPath(
response = await homeAssistantGetSignedURLIfNecessary(
this.hass,
endpoint.endpoint,
endpoint,
JSMPEG_URL_SIGN_EXPIRY_SECONDS,
);
} catch (e) {
+55 -72
View File
@@ -6,13 +6,14 @@ import {
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { customElement, property } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { QueryType } from '../../camera-manager/types.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
import { SignedURLController } from '../../components-lib/signed-url-controller.js';
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
import { CameraConfig } from '../../config/schema/cameras.js';
@@ -21,17 +22,16 @@ import { ViewerConfig } from '../../config/schema/viewer.js';
import { canonicalizeHAURL } from '../../ha/canonical-url.js';
import { isHARelativeURL } from '../../ha/is-ha-relative-url.js';
import { ResolvedMediaCache, resolveMedia } from '../../ha/resolved-media.js';
import { homeAssistantSignPath } from '../../ha/sign-path.js';
import { HomeAssistant, ResolvedMedia } from '../../ha/types.js';
import { createProxiedEndpointIfNecessary } from '../../ha/web-proxy.js';
import { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import '../../patches/ha-hls-player.js';
import viewerProviderStyle from '../../scss/viewer-provider.scss';
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js';
import { errorToConsole } from '../../utils/basic.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { VideoContentType, ViewMedia } from '../../view/item.js';
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js';
import '../image-player.js';
import { renderMessage } from '../message.js';
import { renderProgressIndicator } from '../progress-indicator.js';
import '../video-player.js';
import './../media-dimensions-container';
@@ -60,15 +60,32 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
public cardWideConfig?: CardWideConfig;
private _refProvider: Ref<MediaPlayerElement> = createRef();
private _refContainer: Ref<HTMLElement> = createRef();
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
@state()
private _url: string | null = null;
private _resolvedMediaURL: string | null = null;
private _signedURLController = new SignedURLController(this, () => {
if (!this.hass || !this._resolvedMediaURL) {
return {};
}
// HA-relative URLs need no proxying or signing.
if (isHARelativeURL(this._resolvedMediaURL)) {
return {
endpoint: { endpoint: canonicalizeHAURL(this.hass, this._resolvedMediaURL) },
};
}
const cameraID = this.media?.getCameraID();
const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null;
return {
hass: this.hass,
endpoint: { endpoint: this._resolvedMediaURL },
proxyConfig: camera?.getMediaProxyConfig(),
};
});
constructor() {
super();
this._lazyLoadController.addListener((loaded) => loaded && this._setURL());
this._lazyLoadController.addListener((loaded) => loaded && this._resolveURL());
}
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
@@ -108,69 +125,23 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
});
}
private async _setURL(): Promise<void> {
const mediaContentID = this.media?.getContentID();
if (
!this.media ||
!mediaContentID ||
!this.hass ||
!this._lazyLoadController?.isLoaded()
) {
private async _resolveURL(): Promise<void> {
const contentID = this.media?.getContentID();
if (!contentID || !this.hass || !this._lazyLoadController?.isLoaded()) {
this._resolvedMediaURL = null;
return;
}
let resolvedMedia: ResolvedMedia | null =
this.resolvedMediaCache?.get(mediaContentID) ?? null;
if (!resolvedMedia) {
resolvedMedia = await resolveMedia(
this.hass,
mediaContentID,
this.resolvedMediaCache,
);
}
// Clear immediately so the SignedURLController doesn't see a stale URL
// from the previous media item during the async gap.
this._resolvedMediaURL = null;
if (!resolvedMedia) {
return;
}
const resolved =
this.resolvedMediaCache?.get(contentID) ??
(await resolveMedia(this.hass, contentID, this.resolvedMediaCache));
const unsignedURL = resolvedMedia.url;
if (isHARelativeURL(unsignedURL)) {
// No need to proxy or sign local resolved URLs.
this._url = canonicalizeHAURL(this.hass, unsignedURL);
return;
}
const cameraID = this.media.getCameraID();
const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null;
const proxyConfig = camera?.getProxyConfig();
if (!proxyConfig) {
this._url = unsignedURL;
return;
}
try {
// Create endpoint from unsigned URL - it doesn't need signing initially
const unsignedEndpoint = { endpoint: unsignedURL, sign: false };
const proxiedEndpoint = await createProxiedEndpointIfNecessary(
this.hass,
unsignedEndpoint,
proxyConfig,
{
context: 'media',
// The link may need to be opened multiple times.
openLimit: 0,
},
);
if (proxiedEndpoint.sign) {
this._url = await homeAssistantSignPath(this.hass, proxiedEndpoint.endpoint);
} else {
this._url = proxiedEndpoint.endpoint;
}
} catch (e) {
errorToConsole(e as Error);
}
this._resolvedMediaURL = resolved?.url ?? null;
this.requestUpdate();
}
protected willUpdate(changedProps: PropertyValues): void {
@@ -187,7 +158,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
changedProps.has('resolvedMediaCache') ||
changedProps.has('hass')
) {
this._setURL();
this._resolveURL();
}
if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) {
@@ -260,7 +231,19 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
return;
}
if (!this._url) {
const error = this._signedURLController.getError();
if (error) {
return renderMessage({
type: 'error',
message: localize(
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
),
context: this.media?.getContentID(),
});
}
const url = this._signedURLController.getValue();
if (!url) {
return renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
});
@@ -280,7 +263,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
muted
playsinline
title="${this.media.getTitle() ?? ''}"
url=${this._url}
url=${url}
.hass=${this.hass}
?controls=${this.viewerConfig.controls.builtin}
>
@@ -288,7 +271,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
: html`
<advanced-camera-card-video-player
${ref(this._refProvider)}
url=${this._url}
url=${url}
aria-label="${this.media.getTitle() ?? ''}"
title="${this.media.getTitle() ?? ''}"
?controls=${this.viewerConfig.controls.builtin}
@@ -297,7 +280,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
`
: html`<advanced-camera-card-image-player
${ref(this._refProvider)}
url="${this._url}"
url="${url}"
aria-label="${this.media.getTitle() ?? ''}"
title="${this.media.getTitle() ?? ''}"
@click=${() => {
+3 -16
View File
@@ -5,6 +5,7 @@ import { ptzCameraConfigDefaults, ptzCameraConfigSchema } from './camera/ptz';
import { aspectRatioSchema } from './common/aspect-ratio';
import { eventsMediaTypeSchema } from './common/events-media';
import { imageBaseConfigDefault, imageBaseConfigSchema } from './common/image';
import { proxyBaseConfigDefault, proxyBaseConfigSchema } from './common/proxy';
import { severitySchema } from './common/severity';
const CAMERA_TRIGGER_EVENT_TYPES = [
@@ -143,32 +144,18 @@ export const cameraConfigDefault = {
},
},
proxy: {
dynamic: true,
...proxyBaseConfigDefault,
live: 'auto' as const,
media: 'auto' as const,
ssl_ciphers: 'auto' as const,
ssl_verification: 'auto' as const,
},
go2rtc: go2rtcConfigDefault,
image: imageBaseConfigDefault,
always_error_if_entity_unavailable: false,
};
const SSL_CIPHERS = ['default', 'insecure', 'intermediate', 'modern'] as const;
export type SSLCiphers = (typeof SSL_CIPHERS)[number];
const proxyConfigSchema = z.object({
const proxyConfigSchema = proxyBaseConfigSchema.extend({
live: z.boolean().or(z.literal('auto')).default(cameraConfigDefault.proxy.live),
media: z.boolean().or(z.literal('auto')).default(cameraConfigDefault.proxy.media),
dynamic: z.boolean().default(cameraConfigDefault.proxy.dynamic),
ssl_verification: z
.boolean()
.or(z.literal('auto'))
.default(cameraConfigDefault.proxy.ssl_verification),
ssl_ciphers: z
.enum(SSL_CIPHERS)
.or(z.literal('auto'))
.default(cameraConfigDefault.proxy.ssl_ciphers),
});
const rotationSchema = z
-5
View File
@@ -5,11 +5,6 @@ export const imageBaseConfigDefault = {
refresh_seconds: 1,
};
export const imageConfigDefault = {
...imageBaseConfigDefault,
zoomable: true,
};
const IMAGE_MODES = ['auto', 'camera', 'entity', 'screensaver', 'url'] as const;
export type ImageMode = (typeof IMAGE_MODES)[number];
+45
View File
@@ -0,0 +1,45 @@
import { z } from 'zod';
const SSL_CIPHERS = ['default', 'insecure', 'intermediate', 'modern'] as const;
type SSLCiphers = (typeof SSL_CIPHERS)[number];
export const proxyBaseConfigDefault = {
dynamic: true,
ssl_ciphers: 'auto' as const,
ssl_verification: 'auto' as const,
};
export const proxyBaseConfigSchema = z.object({
dynamic: z.boolean().default(proxyBaseConfigDefault.dynamic),
ssl_verification: z
.boolean()
.or(z.literal('auto'))
.default(proxyBaseConfigDefault.ssl_verification),
ssl_ciphers: z
.enum(SSL_CIPHERS)
.or(z.literal('auto'))
.default(proxyBaseConfigDefault.ssl_ciphers),
});
type UnresolvedProxyConfig = z.output<typeof proxyBaseConfigSchema>;
export interface ResolvedProxyConfig {
dynamic: boolean;
ssl_verification: boolean;
ssl_ciphers: SSLCiphers;
}
export interface EnabledProxyConfig extends ResolvedProxyConfig {
enabled: boolean;
// Whether proxying is a strict requirement. When false, callers may fall
// back to the original URL if the proxy integration is unavailable.
enforce?: boolean;
}
export const resolveProxyConfig = (
config: UnresolvedProxyConfig,
): ResolvedProxyConfig => ({
dynamic: config.dynamic,
ssl_verification: config.ssl_verification === 'auto' ? true : config.ssl_verification,
ssl_ciphers: config.ssl_ciphers === 'auto' ? 'default' : config.ssl_ciphers,
});
+17 -1
View File
@@ -1,9 +1,25 @@
import { z } from 'zod';
import { actionsSchema } from './actions/types';
import { imageBaseConfigSchema, imageConfigDefault } from './common/image';
import { imageBaseConfigDefault, imageBaseConfigSchema } from './common/image';
import { proxyBaseConfigDefault, proxyBaseConfigSchema } from './common/proxy';
export const imageConfigDefault = {
...imageBaseConfigDefault,
proxy: {
...proxyBaseConfigDefault,
enabled: false,
},
zoomable: true,
};
const imageProxyConfigSchema = proxyBaseConfigSchema.extend({
enabled: z.boolean().default(imageConfigDefault.proxy.enabled),
});
export type ImageViewProxyConfig = z.infer<typeof imageProxyConfigSchema>;
export const imageConfigSchema = imageBaseConfigSchema
.extend({
proxy: imageProxyConfigSchema.optional(),
zoomable: z.boolean().default(imageConfigDefault.zoomable),
})
.extend(actionsSchema.shape)
+1 -2
View File
@@ -3,12 +3,11 @@ import { deepRemoveDefaults } from '../../utils/zod/deep-remove-defaults';
import { automationsSchema } from './automations';
import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './cameras';
import { cardIDRegex } from './common/const';
import { imageConfigDefault } from './common/image';
import { DebugConfig, debugConfigDefault, debugConfigSchema } from './debug';
import { dimensionsConfigSchema } from './dimensions';
import { pictureElementsSchema } from './elements/types';
import { foldersConfigSchema } from './folders';
import { imageConfigSchema } from './image';
import { imageConfigDefault, imageConfigSchema } from './image';
import { liveConfigDefault, liveConfigSchema } from './live';
import { mediaGalleryConfigDefault, mediaGalleryConfigSchema } from './media-gallery';
import { menuConfigDefault, menuConfigSchema } from './menu';
+5
View File
@@ -364,6 +364,11 @@ const CONF_IMAGE = 'image' as const;
export const CONF_IMAGE_ENTITY = `${CONF_IMAGE}.entity` as const;
export const CONF_IMAGE_ENTITY_PARAMETERS = `${CONF_IMAGE}.entity_parameters` as const;
export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const;
export const CONF_IMAGE_PROXY_DYNAMIC = `${CONF_IMAGE}.proxy.dynamic` as const;
export const CONF_IMAGE_PROXY_ENABLED = `${CONF_IMAGE}.proxy.enabled` as const;
export const CONF_IMAGE_PROXY_SSL_CIPHERS = `${CONF_IMAGE}.proxy.ssl_ciphers` as const;
export const CONF_IMAGE_PROXY_SSL_VERIFICATION =
`${CONF_IMAGE}.proxy.ssl_verification` as const;
export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const;
export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const;
+108 -55
View File
@@ -110,6 +110,10 @@ import {
CONF_IMAGE_ENTITY,
CONF_IMAGE_ENTITY_PARAMETERS,
CONF_IMAGE_MODE,
CONF_IMAGE_PROXY_DYNAMIC,
CONF_IMAGE_PROXY_ENABLED,
CONF_IMAGE_PROXY_SSL_CIPHERS,
CONF_IMAGE_PROXY_SSL_VERIFICATION,
CONF_IMAGE_REFRESH_SECONDS,
CONF_IMAGE_URL,
CONF_LIVE_AUTO_MUTE,
@@ -315,6 +319,7 @@ const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline';
const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT =
'media_viewer.controls.timeline.format';
const MENU_MEDIA_VIEWER_DISPLAY = 'media_viewer.display';
const MENU_IMAGE_PROXY = 'image.proxy';
const MENU_MENU_BUTTONS = 'menu.buttons';
const MENU_OPTIONS = 'options';
const MENU_PERFORMANCE_FEATURES = 'performance.features';
@@ -388,6 +393,7 @@ const SUBMENU_DOC_LINKS: Record<string, string> = {
[MENU_MEDIA_VIEWER_CONTROLS_TIMELINE]: 'configuration/media-viewer?id=timeline',
[MENU_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT]: 'configuration/media-viewer?id=format',
[MENU_MEDIA_VIEWER_DISPLAY]: 'configuration/media-viewer?id=display',
[MENU_IMAGE_PROXY]: 'configuration/image?id=proxy',
[MENU_MENU_BUTTONS]: 'configuration/menu?id=buttons',
[MENU_OPTIONS]: 'configuration/README',
[MENU_PERFORMANCE_FEATURES]: 'configuration/performance?id=features',
@@ -1053,15 +1059,15 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
{ value: '', label: '' },
{
value: 'auto',
label: localize('config.cameras.proxy.modes.auto'),
label: localize('config.common.proxy.modes.auto'),
},
{
value: true,
label: localize('config.cameras.proxy.modes.true'),
label: localize('config.common.proxy.modes.true'),
},
{
value: false,
label: localize('config.cameras.proxy.modes.false'),
label: localize('config.common.proxy.modes.false'),
},
];
@@ -1069,23 +1075,23 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
{ value: '', label: '' },
{
value: 'auto',
label: localize('config.cameras.proxy.ssl_ciphers.auto'),
label: localize('config.common.proxy.ssl_ciphers.auto'),
},
{
value: 'default',
label: localize('config.cameras.proxy.ssl_ciphers.default'),
label: localize('config.common.proxy.ssl_ciphers.default'),
},
{
value: 'insecure',
label: localize('config.cameras.proxy.ssl_ciphers.insecure'),
label: localize('config.common.proxy.ssl_ciphers.insecure'),
},
{
value: 'intermediate',
label: localize('config.cameras.proxy.ssl_ciphers.intermediate'),
label: localize('config.common.proxy.ssl_ciphers.intermediate'),
},
{
value: 'modern',
label: localize('config.cameras.proxy.ssl_ciphers.modern'),
label: localize('config.common.proxy.ssl_ciphers.modern'),
},
];
@@ -1093,15 +1099,15 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
{ value: '', label: '' },
{
value: 'auto',
label: localize('config.cameras.proxy.ssl_verification.auto'),
label: localize('config.common.proxy.ssl_verification.auto'),
},
{
value: true,
label: localize('config.cameras.proxy.ssl_verification.true'),
label: localize('config.common.proxy.ssl_verification.true'),
},
{
value: false,
label: localize('config.cameras.proxy.ssl_verification.false'),
label: localize('config.common.proxy.ssl_verification.false'),
},
];
@@ -2102,6 +2108,9 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
configPathEntity: string,
configPathEntityParameters: string,
configPathRefreshSeconds: string,
options?: {
proxyMenu?: TemplateResult;
},
): TemplateResult {
return html`
${this._renderOptionSelector(configPathMode, this._imageModes, {
@@ -2123,9 +2132,64 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
${this._renderNumberInput(configPathRefreshSeconds, {
label: localize('config.common.image.refresh_seconds'),
})}
${options?.proxyMenu ?? html``}
`;
}
private _renderProxySubmenu(
domain: string,
key: unknown,
labelPath: string,
configPathDynamic: string,
dynamicDefault: boolean,
configPathSSLCiphers: string,
configPathSSLVerification: string,
options?: {
configPathEnabled?: string;
configPathLive?: string;
configPathMedia?: string;
enabledDefault?: boolean;
},
): TemplateResult {
return this._putInSubmenu(
domain,
key,
labelPath,
'mdi:arrow-decision',
html`
${options?.configPathEnabled !== undefined &&
options.enabledDefault !== undefined
? this._renderSwitch(options.configPathEnabled, options.enabledDefault, {
label: localize('config.common.proxy.modes.true'),
})
: html``}
${options?.configPathLive
? this._renderOptionSelector(options.configPathLive, this._proxyModes, {
label: localize('config.cameras.proxy.live'),
})
: html``}
${options?.configPathMedia
? this._renderOptionSelector(options.configPathMedia, this._proxyModes, {
label: localize('config.cameras.proxy.media'),
})
: html``}
${this._renderSwitch(configPathDynamic, dynamicDefault, {
label: localize('config.common.proxy.dynamic'),
})}
${this._renderOptionSelector(
configPathSSLVerification,
this._proxySSLVerification,
{
label: localize('config.common.proxy.ssl_verification.editor_label'),
},
)}
${this._renderOptionSelector(configPathSSLCiphers, this._proxySSLCiphers, {
label: localize('config.common.proxy.ssl_ciphers.editor_label'),
})}
`,
);
}
private _modifyConfig(func: (config: RawAdvancedCameraCardConfig) => boolean): void {
if (this._config) {
const newConfig = copyConfig(this._config);
@@ -2835,53 +2899,27 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
)}
`,
)}
${this._putInSubmenu(
${this._renderProxySubmenu(
MENU_CAMERAS_PROXY,
cameraIndex,
'config.cameras.proxy.editor_label',
'mdi:arrow-decision',
html`
${this._renderOptionSelector(
getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_LIVE, cameraIndex),
this._proxyModes,
{
label: localize('config.cameras.proxy.live'),
},
)}
${this._renderOptionSelector(
getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_MEDIA, cameraIndex),
this._proxyModes,
{
label: localize('config.cameras.proxy.media'),
},
)}
${this._renderSwitch(
getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_DYNAMIC, cameraIndex),
this._defaults.cameras.proxy.dynamic,
)}
${this._renderOptionSelector(
getArrayConfigPath(
CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION,
cameraIndex,
),
this._proxySSLVerification,
{
label: localize(
'config.cameras.proxy.ssl_verification.editor_label',
),
},
)}
${this._renderOptionSelector(
getArrayConfigPath(
CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS,
cameraIndex,
),
this._proxySSLCiphers,
{
label: localize('config.cameras.proxy.ssl_ciphers.editor_label'),
},
)}
`,
getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_DYNAMIC, cameraIndex),
this._defaults.cameras.proxy.dynamic,
getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS, cameraIndex),
getArrayConfigPath(
CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION,
cameraIndex,
),
{
configPathLive: getArrayConfigPath(
CONF_CAMERAS_ARRAY_PROXY_LIVE,
cameraIndex,
),
configPathMedia: getArrayConfigPath(
CONF_CAMERAS_ARRAY_PROXY_MEDIA,
cameraIndex,
),
},
)}
</div>`
: ``}
@@ -3470,6 +3508,21 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
CONF_IMAGE_ENTITY,
CONF_IMAGE_ENTITY_PARAMETERS,
CONF_IMAGE_REFRESH_SECONDS,
{
proxyMenu: this._renderProxySubmenu(
MENU_IMAGE_PROXY,
true,
'config.common.image.proxy.editor_label',
CONF_IMAGE_PROXY_DYNAMIC,
this._defaults.image.proxy.dynamic,
CONF_IMAGE_PROXY_SSL_CIPHERS,
CONF_IMAGE_PROXY_SSL_VERIFICATION,
{
configPathEnabled: CONF_IMAGE_PROXY_ENABLED,
enabledDefault: this._defaults.image.proxy.enabled,
},
),
},
)}
</div>`
: ''}
+14 -19
View File
@@ -1,7 +1,7 @@
import { ZodSchema } from 'zod';
import { localize } from '../localize/localize';
import { AdvancedCameraCardError, Endpoint } from '../types';
import { homeAssistantSignPath } from './sign-path';
import { homeAssistantGetSignedURLIfNecessary } from './sign-path';
import { HomeAssistant } from './types';
/**
@@ -22,25 +22,20 @@ export const homeAssistantSignAndFetch = async <T>(
timeoutSeconds?: number;
},
): Promise<T> => {
let url: string | null = endpoint.endpoint;
const sign = endpoint.sign;
let url: string | null;
try {
url = await homeAssistantGetSignedURLIfNecessary(hass, endpoint);
} catch (error) {
throw new AdvancedCameraCardError(localize('error.failed_sign'), {
endpoint,
error,
});
}
// Sign the path if needed
if (sign) {
try {
url = await homeAssistantSignPath(hass, url);
} catch (error) {
throw new AdvancedCameraCardError(localize('error.failed_sign'), {
endpoint,
error,
});
}
if (!url) {
throw new AdvancedCameraCardError(localize('error.failed_sign'), {
endpoint,
});
}
if (!url) {
throw new AdvancedCameraCardError(localize('error.failed_sign'), {
endpoint,
});
}
let response: Response;
+20 -2
View File
@@ -1,6 +1,6 @@
import { SignedPath, signedPathSchema } from '../types';
import { homeAssistantWSRequest } from './ws-request';
import { type Endpoint, SignedPath, signedPathSchema } from '../types';
import { HomeAssistant } from './types';
import { homeAssistantWSRequest } from './ws-request';
/**
* Request that HA sign a path. May throw.
@@ -30,3 +30,21 @@ export async function homeAssistantSignPath(
}
return hass.hassUrl(response.path);
}
/**
* Sign an endpoint's path if the endpoint requires signing.
* @param hass The HomeAssistant object used to request the signature.
* @param endpoint The endpoint to potentially sign.
* @param expires An optional number of seconds to sign the path for.
* @returns The signed or unsigned URL, or null if signing failed.
*/
export async function homeAssistantGetSignedURLIfNecessary(
hass: HomeAssistant,
endpoint: Endpoint,
expires?: number,
): Promise<string | null> {
if (!endpoint.sign) {
return endpoint.endpoint;
}
return await homeAssistantSignPath(hass, endpoint.endpoint, expires);
}
+21 -27
View File
@@ -1,4 +1,4 @@
import { CameraProxyConfig } from '../camera-manager/types';
import { EnabledProxyConfig, ResolvedProxyConfig } from '../config/schema/common/proxy';
import { Endpoint } from '../types';
import { HomeAssistant } from './types';
@@ -26,22 +26,12 @@ export const getWebProxiedURL = (url: string, options?: ProxiedURLOptions): stri
);
};
export const shouldUseWebProxy = (
hass: HomeAssistant,
proxyConfig: CameraProxyConfig,
context: 'media' | 'live' = 'media',
): boolean => {
return hasWebProxyAvailable(hass) && !!proxyConfig[context];
};
export async function addDynamicProxyURL(
hass: HomeAssistant,
url_pattern: string,
options?: {
proxyConfig?: CameraProxyConfig;
proxyConfig?: ResolvedProxyConfig;
urlID?: string;
sslVerification?: boolean;
sslCiphers?: string;
openLimit?: number;
ttl?: number;
allowUnauthenticated?: boolean;
@@ -51,9 +41,8 @@ export async function addDynamicProxyURL(
url_pattern: url_pattern,
...(options && {
url_id: options.urlID,
ssl_verification:
options.sslVerification ?? options?.proxyConfig?.ssl_verification,
ssl_ciphers: options.sslCiphers ?? options?.proxyConfig?.ssl_ciphers,
ssl_verification: options.proxyConfig?.ssl_verification,
ssl_ciphers: options.proxyConfig?.ssl_ciphers,
open_limit: options.openLimit,
ttl: options.ttl,
allow_unauthenticated: options.allowUnauthenticated,
@@ -61,8 +50,7 @@ export async function addDynamicProxyURL(
});
}
interface CreateProxiedEndpointOptions {
context?: 'live' | 'media';
export interface CreateProxiedEndpointOptions {
ttl?: number;
websocket?: boolean;
openLimit?: number;
@@ -73,25 +61,31 @@ interface CreateProxiedEndpointOptions {
* Handles dynamic proxy registration and returns a proxied Endpoint.
* @param hass Home Assistant instance.
* @param endpoint The endpoint to potentially proxy.
* @param proxyConfig The camera proxy configuration. If undefined, returns original endpoint.
* @param proxyConfig The proxy configuration. If undefined or not enabled,
* returns the original endpoint.
* @param options Additional options for proxy registration.
* @returns Proxied Endpoint if proxying needed, original endpoint otherwise.
* @returns Proxied Endpoint if proxying needed, original endpoint if proxying
* is not enabled, or null if proxying is required but unavailable.
*/
export const createProxiedEndpointIfNecessary = async (
hass: HomeAssistant,
endpoint: Endpoint,
proxyConfig?: CameraProxyConfig,
proxyConfig?: EnabledProxyConfig,
options?: CreateProxiedEndpointOptions,
): Promise<Endpoint> => {
const context = options?.context ?? 'media';
if (!proxyConfig || !shouldUseWebProxy(hass, proxyConfig, context)) {
): Promise<Endpoint | null> => {
if (!proxyConfig || !proxyConfig.enabled) {
return endpoint;
}
if (!hasWebProxyAvailable(hass)) {
return proxyConfig.enforce === true ? null : endpoint;
}
if (proxyConfig.dynamic) {
// Strip hash fragment for registration - it's client-side only and
// not relevant for proxy pattern matching.
const registrationUrl = endpoint.endpoint.split(/#/)[0];
await addDynamicProxyURL(hass, registrationUrl, {
// Strip hash fragment — it's client-side only and not relevant for
// proxy pattern matching.
const url = endpoint.endpoint.split(/#/)[0];
await addDynamicProxyURL(hass, url, {
proxyConfig,
ttl: options?.ttl,
openLimit: options?.openLimit ?? 0,
+29 -27
View File
@@ -64,38 +64,18 @@
"rotation": "Rotation",
"rotations": {
"0": "Keine Rotation",
"90": "90 Grad im Uhrzeigersinn",
"180": "180 Grad im Uhrzeigersinn",
"270": "270 Grad im Uhrzeigersinn"
"270": "270 Grad im Uhrzeigersinn",
"90": "90 Grad im Uhrzeigersinn"
}
},
"go2rtc": {
"url": "go2rtc URL"
},
"proxy": {
"dynamic": "Dynamischer Proxy",
"editor_label": "Kamera Proxy",
"live": "Live Proxy",
"media": "Medien Proxy",
"modes": {
"auto": "Proxy automatisch konfigurieren",
"false": "Proxy deaktiviert",
"true": "Proxy aktiviert"
},
"ssl_ciphers": {
"auto": "SSL ciphers automatisch konfiguriert",
"default": "Standard SSL ciphers",
"editor_label": "SSL ciphers",
"insecure": "Unsichere SSL ciphers",
"intermediate": "Zwischen SSL ciphers",
"modern": "Moderne SSL ciphers"
},
"ssl_verification": {
"auto": "SSL Überprüfung automatisch konfiguriert",
"editor_label": "SSL Überprüfung",
"false": "SSL Überprüfung deaktiviert",
"true": "SSL Überprüfung aktiviert"
}
"media": "Medien Proxy"
},
"reolink": {
"editor_label": "Reolink Optionen",
@@ -116,10 +96,6 @@
}
},
"common": {
"media_types": {
"events": "Ereignisse",
"recordings": "Aufzeichnungen"
},
"controls": {
"builtin": "Eingebettete Video Bedienelemente",
"thumbnails": {
@@ -165,6 +141,32 @@
"microphone_mute": "Bei Mikrofon Stummschaltung",
"microphone_unmute": "Bei Mikrofon Reaktivierung"
},
"media_types": {
"events": "Ereignisse",
"recordings": "Aufzeichnungen"
},
"proxy": {
"dynamic": "Dynamischer Proxy",
"modes": {
"auto": "Proxy automatisch konfigurieren",
"false": "Proxy deaktiviert",
"true": "Proxy aktiviert"
},
"ssl_ciphers": {
"auto": "SSL ciphers automatisch konfiguriert",
"default": "Standard SSL ciphers",
"editor_label": "SSL ciphers",
"insecure": "Unsichere SSL ciphers",
"intermediate": "Zwischen SSL ciphers",
"modern": "Moderne SSL ciphers"
},
"ssl_verification": {
"auto": "SSL Überprüfung automatisch konfiguriert",
"editor_label": "SSL Überprüfung",
"false": "SSL Überprüfung deaktiviert",
"true": "SSL Überprüfung aktiviert"
}
},
"timeline": {
"style": "Zeitleisten Stil",
"styles": {
+26 -21
View File
@@ -173,29 +173,9 @@
"url": "MotionEye UI URL"
},
"proxy": {
"dynamic": "Dynamic proxying",
"editor_label": "Camera proxying",
"live": "Live proxying",
"media": "Media proxying",
"modes": {
"auto": "Proxying automatically configured",
"false": "Proxying disabled",
"true": "Proxying enabled"
},
"ssl_ciphers": {
"auto": "SSL ciphers automatically configured",
"default": "Default SSL ciphers",
"editor_label": "SSL ciphers",
"insecure": "Insecure SSL ciphers",
"intermediate": "Intermediate SSL ciphers",
"modern": "Modern SSL ciphers"
},
"ssl_verification": {
"auto": "SSL verification automatically configured",
"editor_label": "SSL verification",
"false": "SSL verification disabled",
"true": "SSL verification enabled"
}
"media": "Media proxying"
},
"reolink": {
"editor_label": "Reolink options",
@@ -322,6 +302,9 @@
"screensaver": "Embedded screensaver image",
"url": "Arbitrary image specified by URL"
},
"proxy": {
"editor_label": "Image proxying"
},
"refresh_seconds": "Number of seconds after which to refresh (0=never)",
"url": "Static image URL"
},
@@ -342,6 +325,28 @@
"recordings": "Recordings",
"reviews": "Reviews"
},
"proxy": {
"dynamic": "Dynamic proxying",
"modes": {
"auto": "Proxying automatically configured",
"false": "Proxying disabled",
"true": "Proxying enabled"
},
"ssl_ciphers": {
"auto": "SSL ciphers automatically configured",
"default": "Default SSL ciphers",
"editor_label": "SSL ciphers",
"insecure": "Insecure SSL ciphers",
"intermediate": "Intermediate SSL ciphers",
"modern": "Modern SSL ciphers"
},
"ssl_verification": {
"auto": "SSL verification automatically configured",
"editor_label": "SSL verification",
"false": "SSL verification disabled",
"true": "SSL verification enabled"
}
},
"timeline": {
"clustering_threshold": "The count of events at which they are clustered (0=no clustering)",
"events_media_type": "The events media the timeline displays",
+32 -30
View File
@@ -127,27 +127,7 @@
"url": "URL de l’interface MotionEye"
},
"proxy": {
"dynamic": "Proxy dynamique",
"editor_label": "Proxy de la caméra",
"modes": {
"auto": "Proxy automatique",
"false": "Proxy désactivé",
"true": "Proxy activé"
},
"ssl_ciphers": {
"auto": "Chiffrement SSL automatique",
"default": "Chiffrement SSL par défaut",
"editor_label": "Chiffrement SSL",
"insecure": "Chiffrement SSL non sécurisé",
"intermediate": "Chiffrement SSL intermédiaire",
"modern": "Chiffrement SSL moderne"
},
"ssl_verification": {
"auto": "Vérification SSL automatique",
"editor_label": "Vérification SSL",
"false": "Vérification SSL désactivée",
"true": "Vérification SSL activée"
}
"editor_label": "Proxy de la caméra"
},
"reolink": {
"editor_label": "Option Reolink",
@@ -178,15 +158,6 @@
}
},
"common": {
"media_types": {
"events": "Événements",
"recordings": "Enregistrements"
},
"events_media_types": {
"all": "Tous types de médias",
"clips": "Extraits",
"snapshots": "Instantanés"
},
"controls": {
"builtin": "Commandes vidéo intégrées",
"filter": {
@@ -257,6 +228,11 @@
"grid_selected_width_factor": "Augmenter la largeur du média sélectionnée par ce facteur",
"mode": "Mode"
},
"events_media_types": {
"all": "Tous types de médias",
"clips": "Extraits",
"snapshots": "Instantanés"
},
"image": {
"entity": "Entité à utiliser avec le mode entité",
"entity_parameters": "Paramètres de requête ajoutés aux URL des images basées sur l'entité (par exemple, width=1920&height=1080)",
@@ -279,6 +255,32 @@
"unselected": "Lors de la désélection",
"visible": "Sur la visibilité du navigateur/onglet"
},
"media_types": {
"events": "Événements",
"recordings": "Enregistrements"
},
"proxy": {
"dynamic": "Proxy dynamique",
"modes": {
"auto": "Proxy automatique",
"false": "Proxy désactivé",
"true": "Proxy activé"
},
"ssl_ciphers": {
"auto": "Chiffrement SSL automatique",
"default": "Chiffrement SSL par défaut",
"editor_label": "Chiffrement SSL",
"insecure": "Chiffrement SSL non sécurisé",
"intermediate": "Chiffrement SSL intermédiaire",
"modern": "Chiffrement SSL moderne"
},
"ssl_verification": {
"auto": "Vérification SSL automatique",
"editor_label": "Vérification SSL",
"false": "Vérification SSL désactivée",
"true": "Vérification SSL activée"
}
},
"timeline": {
"clustering_threshold": "Nombre d'événements pour lesquels ils sont regroupés (0 = pas de clustering)",
"events_media_type": "Médias affichés par la chronologie",
+25 -23
View File
@@ -83,9 +83,9 @@
"rotation": "Obrót",
"rotations": {
"0": "Brak obrotu",
"90": "90 stopni zgodnie z zegarem",
"180": "180 stopni zgodnie z zegarem",
"270": "270 stopni zgodnie z zegarem"
"270": "270 stopni zgodnie z zegarem",
"90": "90 stopni zgodnie z zegarem"
}
},
"engines": {
@@ -143,29 +143,9 @@
"url": "URL interfejsu MotionEye"
},
"proxy": {
"dynamic": "Proxy dynamiczne",
"editor_label": "Proxy kamery",
"live": "Proxy na żywo",
"media": "Proxy mediów",
"modes": {
"auto": "Proxy skonfigurowane automatycznie",
"false": "Proxy wyłączone",
"true": "Proxy włączone"
},
"ssl_ciphers": {
"auto": "Szyfry SSL skonfigurowane automatycznie",
"default": "Domyślne szyfry SSL",
"editor_label": "Szyfry SSL",
"insecure": "Niezabezpieczone szyfry SSL",
"intermediate": "Pośrednie szyfry SSL",
"modern": "Nowoczesne szyfry SSL"
},
"ssl_verification": {
"auto": "Weryfikacja SSL skonfigurowana automatycznie",
"editor_label": "Weryfikacja SSL",
"false": "Weryfikacja SSL wyłączona",
"true": "Weryfikacja SSL włączona"
}
"media": "Proxy mediów"
},
"reolink": {
"editor_label": "Opcje Reolink",
@@ -302,6 +282,28 @@
"unselected": "Po odznaczeniu",
"visible": "Gdy przeglądarka/karta jest widoczna"
},
"proxy": {
"dynamic": "Proxy dynamiczne",
"modes": {
"auto": "Proxy skonfigurowane automatycznie",
"false": "Proxy wyłączone",
"true": "Proxy włączone"
},
"ssl_ciphers": {
"auto": "Szyfry SSL skonfigurowane automatycznie",
"default": "Domyślne szyfry SSL",
"editor_label": "Szyfry SSL",
"insecure": "Niezabezpieczone szyfry SSL",
"intermediate": "Pośrednie szyfry SSL",
"modern": "Nowoczesne szyfry SSL"
},
"ssl_verification": {
"auto": "Weryfikacja SSL skonfigurowana automatycznie",
"editor_label": "Weryfikacja SSL",
"false": "Weryfikacja SSL wyłączona",
"true": "Weryfikacja SSL włączona"
}
},
"timeline": {
"clustering_threshold": "Liczba zdarzeń, przy której są grupowane (0=brak)",
"events_media_type": "Typ mediów wyświetlany na osi czasu",
+2 -2
View File
@@ -1,7 +1,7 @@
import { CameraProxyConfig } from '../camera-manager/types';
import { supports2WayAudio as gortcSupports2WayAudio } from '../camera-manager/utils/go2rtc/audio';
import { CameraConfig } from '../config/schema/cameras';
import { LiveProvider } from '../config/schema/cameras.js';
import { EnabledProxyConfig } from '../config/schema/common/proxy';
import { HomeAssistant } from '../ha/types';
import { Endpoint } from '../types';
@@ -27,7 +27,7 @@ export const liveProviderSupports2WayAudio = async (
config: CameraConfig,
metadataFetchTimeoutSeconds: number,
go2rtcMetadataEndpoint?: Endpoint | null,
proxyConfig?: CameraProxyConfig,
proxyConfig?: EnabledProxyConfig,
): Promise<boolean> => {
if (getResolvedLiveProvider(config) !== 'go2rtc') {
return false;