fix: Improve 2-way audio detection (#2293)
- For Frigate cameras, you must be running at least [integration v5.12.0](https://github.com/blakeblackshear/frigate-hass-integration/releases/tag/v5.12.0). - Closes: #2191 Includes a significant refactor of cameras, and the introduction of a `2-way-audio` capability that is dynamically fetched from `go2rtc`. One gotcha is if you previously had a substream with 2-way audio, you may need to modify ```yaml - camera_entity: camera.foo capabilities: disable_except: - substream ``` ... to ... ```yaml - camera_entity: camera.foo capabilities: disable_except: - substream - 2-way-audio ```
This commit is contained in:
@@ -3,17 +3,41 @@ import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state
|
||||
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
|
||||
import { CameraConfig } from '../config/schema/cameras';
|
||||
import { isTriggeredState } from '../ha/is-triggered-state';
|
||||
import { HassStateDifference } from '../ha/types';
|
||||
import { HassStateDifference, HomeAssistant } from '../ha/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { CapabilitiesRaw, CapabilityKey, Endpoint } from '../types';
|
||||
import { liveProviderSupports2WayAudio } from '../utils/live-provider';
|
||||
import { Capabilities } from './capabilities';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { CameraNoIDError } from './error';
|
||||
import { CameraEventCallback, CameraProxyConfig } from './types';
|
||||
import {
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraEventCallback,
|
||||
CameraProxyConfig,
|
||||
} from './types';
|
||||
import {
|
||||
getGo2RTCMetadataEndpoint,
|
||||
getGo2RTCStreamEndpoint,
|
||||
} from './utils/go2rtc/endpoint';
|
||||
import { getConfiguredPTZAction } from './utils/ptz';
|
||||
|
||||
export interface CameraInitializationOptions {
|
||||
stateWatcher: StateWatcherSubscriptionInterface;
|
||||
interface CapabilityOptions {
|
||||
// Pre-built Capabilities object.
|
||||
capabilities?: Capabilities;
|
||||
|
||||
// Raw capabilities for construction.
|
||||
raw?: CapabilitiesRaw;
|
||||
disable?: CapabilityKey[];
|
||||
disableExcept?: CapabilityKey[];
|
||||
}
|
||||
|
||||
export interface CameraInitializationOptions {
|
||||
hass: HomeAssistant;
|
||||
stateWatcher: StateWatcherSubscriptionInterface;
|
||||
capabilityOptions?: CapabilityOptions;
|
||||
}
|
||||
|
||||
type DestroyCallback = () => void | Promise<void>;
|
||||
|
||||
export class Camera {
|
||||
@@ -27,22 +51,73 @@ export class Camera {
|
||||
config: CameraConfig,
|
||||
engine: CameraManagerEngine,
|
||||
options?: {
|
||||
capabilities?: Capabilities;
|
||||
eventCallback?: CameraEventCallback;
|
||||
capabilities?: Capabilities;
|
||||
},
|
||||
) {
|
||||
this._config = config;
|
||||
this._engine = engine;
|
||||
this._capabilities = options?.capabilities;
|
||||
this._eventCallback = options?.eventCallback;
|
||||
this._capabilities = options?.capabilities;
|
||||
}
|
||||
|
||||
async initialize(options: CameraInitializationOptions): Promise<Camera> {
|
||||
await this._initialize(options);
|
||||
this._capabilities =
|
||||
options.capabilityOptions?.capabilities ??
|
||||
this._capabilities ??
|
||||
(await this._buildCapabilities(options));
|
||||
this._subscribeBasedOnCapabilities(options.stateWatcher);
|
||||
this._onDestroy(() => options.stateWatcher.unsubscribe(this._stateChangeHandler));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subclass initialization hook. Override for async initialization work.
|
||||
*/
|
||||
protected async _initialize(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_options: CameraInitializationOptions,
|
||||
): Promise<void> {}
|
||||
|
||||
protected async _buildCapabilities(
|
||||
options: CameraInitializationOptions,
|
||||
): Promise<Capabilities> {
|
||||
const rawCapabilities = await this._getRawCapabilities(options);
|
||||
const config = this.getConfig();
|
||||
const has2WayAudio = await liveProviderSupports2WayAudio(
|
||||
options.hass,
|
||||
config,
|
||||
this._getGo2RTCMetadataEndpoint(),
|
||||
this.getProxyConfig(),
|
||||
);
|
||||
|
||||
return new Capabilities(
|
||||
{ ...rawCapabilities, '2-way-audio': has2WayAudio },
|
||||
{
|
||||
disable: config.capabilities?.disable,
|
||||
disableExcept: config.capabilities?.disable_except,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw capabilities for this camera. Subclasses should override
|
||||
* and call super._getRawCapabilities() to extend defaults.
|
||||
*/
|
||||
protected async _getRawCapabilities(
|
||||
options: CameraInitializationOptions,
|
||||
): Promise<CapabilitiesRaw> {
|
||||
return {
|
||||
live: true,
|
||||
menu: true,
|
||||
substream: true,
|
||||
trigger: true,
|
||||
'remote-control-entity': true,
|
||||
...options.capabilityOptions?.raw,
|
||||
};
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
this._destroyCallbacks.forEach((callback) => callback());
|
||||
}
|
||||
@@ -70,6 +145,45 @@ export class Camera {
|
||||
return this._capabilities ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get camera endpoints. Subclasses should override to add engine-specific endpoints.
|
||||
* @param _context Optional context for dynamic endpoints (e.g., UI URLs based on current view).
|
||||
*/
|
||||
public getEndpoints(context?: CameraEndpointsContext): CameraEndpoints | null {
|
||||
const ui = this._getUIEndpoint(context);
|
||||
const go2rtc = this._getGo2RTCStreamEndpoint();
|
||||
const webrtcCard = this._getWebRTCCardEndpoint();
|
||||
|
||||
return ui || go2rtc || webrtcCard
|
||||
? {
|
||||
...(ui && { ui }),
|
||||
...(go2rtc && { go2rtc }),
|
||||
...(webrtcCard && { webrtcCard }),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the go2rtc metadata endpoint for capability detection.
|
||||
* Subclasses should override if they have custom go2rtc URL or stream resolution.
|
||||
*/
|
||||
protected _getGo2RTCMetadataEndpoint(): Endpoint | null {
|
||||
return getGo2RTCMetadataEndpoint(this._config);
|
||||
}
|
||||
|
||||
protected _getGo2RTCStreamEndpoint(): Endpoint | null {
|
||||
return getGo2RTCStreamEndpoint(this._config);
|
||||
}
|
||||
|
||||
protected _getWebRTCCardEndpoint(): Endpoint | null {
|
||||
return this._config.camera_entity ? { endpoint: this._config.camera_entity } : null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected _getUIEndpoint(_context?: CameraEndpointsContext): Endpoint | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
public getProxyConfig(): CameraProxyConfig {
|
||||
return {
|
||||
live:
|
||||
|
||||
@@ -6,8 +6,6 @@ import { ViewItemCapabilities } from '../view/types';
|
||||
import { Camera } from './camera';
|
||||
import { CameraManagerReadOnlyConfigStore } from './store';
|
||||
import {
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraQuery,
|
||||
Engine,
|
||||
@@ -122,9 +120,4 @@ export interface CameraManagerEngine {
|
||||
): CameraManagerCameraMetadata;
|
||||
|
||||
getMediaCapabilities(media: ViewMedia): ViewItemCapabilities | null;
|
||||
|
||||
getCameraEndpoints(
|
||||
cameraConfig: CameraConfig,
|
||||
context?: CameraEndpointsContext,
|
||||
): CameraEndpoints | null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Entity, EntityRegistryManager } from '../ha/registry/entity/types';
|
||||
import { HomeAssistant } from '../ha/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { Camera, CameraInitializationOptions } from './camera';
|
||||
import { CameraInitializationError } from './error';
|
||||
@@ -7,7 +6,6 @@ import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
|
||||
|
||||
export interface EntityCameraInitializationOptions extends CameraInitializationOptions {
|
||||
entityRegistryManager: EntityRegistryManager;
|
||||
hass: HomeAssistant;
|
||||
}
|
||||
|
||||
export class EntityCamera extends Camera {
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { format } from 'date-fns';
|
||||
import { uniq } from 'lodash-es';
|
||||
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 { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { PTZCapabilities, PTZMovementType } from '../../types';
|
||||
import {
|
||||
CapabilitiesRaw,
|
||||
Endpoint,
|
||||
PTZCapabilities,
|
||||
PTZMovementType,
|
||||
} from '../../types';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import { Camera, CameraInitializationOptions } from '../camera';
|
||||
import { Capabilities } from '../capabilities';
|
||||
import { CameraManagerEngine } from '../engine';
|
||||
import { CameraInitializationError } from '../error';
|
||||
import { CameraEventCallback } from '../types';
|
||||
import { CameraEndpoints, CameraEndpointsContext } from '../types';
|
||||
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
|
||||
import {
|
||||
getGo2RTCMetadataEndpoint,
|
||||
getGo2RTCStreamEndpoint,
|
||||
} from '../utils/go2rtc/endpoint';
|
||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||
import {
|
||||
FrigateEventWatcherRequest,
|
||||
@@ -27,8 +34,6 @@ const CAMERA_BIRDSEYE = 'birdseye' as const;
|
||||
interface FrigateCameraInitializationOptions extends CameraInitializationOptions {
|
||||
entityRegistryManager: EntityRegistryManager;
|
||||
frigateEventWatcher: FrigateEventWatcherSubscriptionInterface;
|
||||
hass: HomeAssistant;
|
||||
stateWatcher: StateWatcherSubscriptionInterface;
|
||||
}
|
||||
|
||||
export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
|
||||
@@ -36,26 +41,15 @@ export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
|
||||
};
|
||||
|
||||
export class FrigateCamera extends Camera {
|
||||
constructor(
|
||||
config: CameraConfig,
|
||||
engine: CameraManagerEngine,
|
||||
options?: {
|
||||
capabilities?: Capabilities;
|
||||
eventCallback?: CameraEventCallback;
|
||||
},
|
||||
) {
|
||||
super(config, engine, options);
|
||||
}
|
||||
|
||||
public async initialize(options: FrigateCameraInitializationOptions): Promise<Camera> {
|
||||
await this._initializeConfig(options.hass, options.entityRegistryManager);
|
||||
await this._initializeCapabilities(options.hass, options.stateWatcher);
|
||||
await super.initialize(options);
|
||||
|
||||
if (this._capabilities?.has('trigger')) {
|
||||
await this._subscribeToEvents(options.hass, options.frigateEventWatcher);
|
||||
}
|
||||
|
||||
return await super.initialize(options);
|
||||
return this;
|
||||
}
|
||||
|
||||
public async executePTZAction(
|
||||
@@ -170,45 +164,27 @@ export class FrigateCamera extends Camera {
|
||||
}
|
||||
}
|
||||
|
||||
protected async _initializeCapabilities(
|
||||
hass: HomeAssistant,
|
||||
stateWatcher: StateWatcherSubscriptionInterface,
|
||||
): Promise<void> {
|
||||
protected async _getRawCapabilities(
|
||||
options: FrigateCameraInitializationOptions,
|
||||
): Promise<CapabilitiesRaw> {
|
||||
const base = await super._getRawCapabilities(options);
|
||||
const config = this.getConfig();
|
||||
|
||||
const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig());
|
||||
const frigatePTZCapabilities = await this._getPTZCapabilities(hass, config);
|
||||
|
||||
const combinedPTZCapabilities: PTZCapabilities | null =
|
||||
configPTZCapabilities || frigatePTZCapabilities
|
||||
? {
|
||||
...frigatePTZCapabilities,
|
||||
...configPTZCapabilities,
|
||||
}
|
||||
: null;
|
||||
const frigatePTZ = await this._getPTZCapabilities(options.hass, config);
|
||||
const configPTZ = getPTZCapabilitiesFromCameraConfig(config);
|
||||
const combinedPTZ: PTZCapabilities | null =
|
||||
configPTZ || frigatePTZ ? { ...frigatePTZ, ...configPTZ } : null;
|
||||
|
||||
const birdseye = isBirdseye(config);
|
||||
this._capabilities = new Capabilities(
|
||||
{
|
||||
'favorite-events': !birdseye,
|
||||
'favorite-recordings': false,
|
||||
'remote-control-entity': true,
|
||||
seek: !birdseye,
|
||||
clips: !birdseye,
|
||||
snapshots: !birdseye,
|
||||
recordings: !birdseye,
|
||||
live: true,
|
||||
menu: true,
|
||||
substream: true,
|
||||
trigger: true,
|
||||
...(combinedPTZCapabilities && { ptz: combinedPTZCapabilities }),
|
||||
},
|
||||
{
|
||||
disable: config.capabilities?.disable,
|
||||
disableExcept: config.capabilities?.disable_except,
|
||||
},
|
||||
);
|
||||
this._subscribeBasedOnCapabilities(stateWatcher);
|
||||
return {
|
||||
...base,
|
||||
'favorite-events': !birdseye,
|
||||
seek: !birdseye,
|
||||
clips: !birdseye,
|
||||
snapshots: !birdseye,
|
||||
recordings: !birdseye,
|
||||
...(combinedPTZ && { ptz: combinedPTZ }),
|
||||
};
|
||||
}
|
||||
|
||||
protected _getFrigateCameraNameFromEntity(entity: Entity): string | null {
|
||||
@@ -225,6 +201,100 @@ export class FrigateCamera extends Camera {
|
||||
return null;
|
||||
}
|
||||
|
||||
public override getEndpoints(
|
||||
context?: CameraEndpointsContext,
|
||||
): CameraEndpoints | null {
|
||||
const base = super.getEndpoints(context);
|
||||
const jsmpeg = this._getJSMPEGEndpoint();
|
||||
|
||||
if (!base && !jsmpeg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(jsmpeg && { jsmpeg }),
|
||||
};
|
||||
}
|
||||
|
||||
protected override _getGo2RTCMetadataEndpoint(): Endpoint | null {
|
||||
const stream = this._config.go2rtc?.stream ?? this._config.frigate.camera_name;
|
||||
const url =
|
||||
this._config.go2rtc?.url ??
|
||||
`/api/frigate/${this._config.frigate.client_id}/go2rtc`;
|
||||
return getGo2RTCMetadataEndpoint(this._config, { url, stream });
|
||||
}
|
||||
|
||||
protected override _getGo2RTCStreamEndpoint(): Endpoint | null {
|
||||
const stream = this._config.go2rtc?.stream ?? this._config.frigate.camera_name;
|
||||
const url =
|
||||
this._config.go2rtc?.url ??
|
||||
// go2rtc is exposed by the Frigate integration under the 'mse' path.
|
||||
`/api/frigate/${this._config.frigate.client_id}/mse`;
|
||||
|
||||
return getGo2RTCStreamEndpoint(this._config, {
|
||||
url,
|
||||
stream,
|
||||
});
|
||||
}
|
||||
|
||||
protected _getJSMPEGEndpoint(): Endpoint | null {
|
||||
if (!this._config.frigate.camera_name) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
endpoint:
|
||||
`/api/frigate/${this._config.frigate.client_id}` +
|
||||
`/jsmpeg/${this._config.frigate.camera_name}`,
|
||||
sign: true,
|
||||
};
|
||||
}
|
||||
|
||||
protected override _getUIEndpoint(context?: CameraEndpointsContext): Endpoint | null {
|
||||
if (!this._config.frigate.url) {
|
||||
return null;
|
||||
}
|
||||
if (!this._config.frigate.camera_name) {
|
||||
return { endpoint: this._config.frigate.url };
|
||||
}
|
||||
|
||||
const cameraURL = `${this._config.frigate.url}/#${this._config.frigate.camera_name}`;
|
||||
|
||||
if (context?.view === 'live') {
|
||||
return { endpoint: cameraURL };
|
||||
}
|
||||
|
||||
const eventsURL = `${this._config.frigate.url}/events?camera=${this._config.frigate.camera_name}`;
|
||||
const recordingsURL = `${this._config.frigate.url}/recording/${this._config.frigate.camera_name}`;
|
||||
|
||||
// If media is available, use it for a more precise URL.
|
||||
switch (context?.media?.getMediaType()) {
|
||||
case 'clip':
|
||||
case 'snapshot':
|
||||
return { endpoint: eventsURL };
|
||||
case 'recording':
|
||||
const startTime = context.media.getStartTime();
|
||||
return {
|
||||
endpoint:
|
||||
recordingsURL + (startTime ? '/' + format(startTime, 'yyyy-MM-dd/HH') : ''),
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to using the view.
|
||||
switch (context?.view) {
|
||||
case 'clip':
|
||||
case 'clips':
|
||||
case 'snapshots':
|
||||
case 'snapshot':
|
||||
return { endpoint: eventsURL };
|
||||
case 'recording':
|
||||
case 'recordings':
|
||||
return { endpoint: recordingsURL };
|
||||
}
|
||||
|
||||
return { endpoint: cameraURL };
|
||||
}
|
||||
|
||||
protected async _getPTZCapabilities(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
@@ -372,6 +442,14 @@ export class FrigateCamera extends Camera {
|
||||
const clipChange = !ev.before.has_clip && ev.after.has_clip;
|
||||
|
||||
const config = this.getConfig();
|
||||
const cameraID = this._config.id;
|
||||
|
||||
if (!cameraID) {
|
||||
// This can happen if an event arrives during the time a camera is
|
||||
// initializing.
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(config.frigate.zones?.length &&
|
||||
!config.frigate.zones.some((zone) => ev.after.current_zones.includes(zone))) ||
|
||||
@@ -392,8 +470,9 @@ export class FrigateCamera extends Camera {
|
||||
}
|
||||
|
||||
this._eventCallback?.({
|
||||
cameraID,
|
||||
|
||||
fidelity: 'high',
|
||||
cameraID: this.getID(),
|
||||
type: ev.type,
|
||||
// In cases where there are both clip and snapshot media, ensure to only
|
||||
// trigger on the media type that is allowed by the configuration.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { add, endOfHour, format, fromUnixTime, startOfHour } from 'date-fns';
|
||||
import { add, endOfHour, fromUnixTime, startOfHour } from 'date-fns';
|
||||
import { isEqual, orderBy, throttle, uniqWith } from 'lodash-es';
|
||||
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
||||
import { CameraConfig } from '../../config/schema/cameras';
|
||||
@@ -25,8 +25,6 @@ import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||
import { DateRange } from '../range';
|
||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||
import {
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraEventCallback,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerRequestCache,
|
||||
@@ -53,7 +51,6 @@ import {
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
} from '../types';
|
||||
import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint';
|
||||
import { FrigateCamera, isBirdseye } from './camera';
|
||||
import { FrigateEventWatcher } from './event-watcher';
|
||||
import { FrigateViewMediaFactory } from './media';
|
||||
@@ -920,87 +917,4 @@ export class FrigateCameraManagerEngine
|
||||
engineIcon: 'frigate',
|
||||
};
|
||||
}
|
||||
|
||||
public getCameraEndpoints(
|
||||
cameraConfig: CameraConfig,
|
||||
context?: CameraEndpointsContext,
|
||||
): CameraEndpoints | null {
|
||||
const getUIEndpoint = (): Endpoint | null => {
|
||||
if (!cameraConfig.frigate.url) {
|
||||
return null;
|
||||
}
|
||||
if (!cameraConfig.frigate.camera_name) {
|
||||
return { endpoint: cameraConfig.frigate.url };
|
||||
}
|
||||
|
||||
const cameraURL =
|
||||
`${cameraConfig.frigate.url}/#` + cameraConfig.frigate.camera_name;
|
||||
|
||||
if (context?.view === 'live') {
|
||||
return { endpoint: cameraURL };
|
||||
}
|
||||
|
||||
const eventsURL =
|
||||
`${cameraConfig.frigate.url}/events?camera=` + cameraConfig.frigate.camera_name;
|
||||
const recordingsURL =
|
||||
`${cameraConfig.frigate.url}/recording/` + cameraConfig.frigate.camera_name;
|
||||
|
||||
// If media is available, use it since it may result in a more precisely
|
||||
// correct URL.
|
||||
switch (context?.media?.getMediaType()) {
|
||||
case 'clip':
|
||||
case 'snapshot':
|
||||
return { endpoint: eventsURL };
|
||||
case 'recording':
|
||||
const startTime = context.media.getStartTime();
|
||||
if (startTime) {
|
||||
return { endpoint: recordingsURL + format(startTime, 'yyyy-MM-dd/HH') };
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, fall back to just using the view if we have that.
|
||||
switch (context?.view) {
|
||||
case 'clip':
|
||||
case 'clips':
|
||||
case 'snapshots':
|
||||
case 'snapshot':
|
||||
return { endpoint: eventsURL };
|
||||
case 'recording':
|
||||
case 'recordings':
|
||||
return { endpoint: recordingsURL };
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: cameraURL,
|
||||
};
|
||||
};
|
||||
|
||||
const getJSMPEG = (): Endpoint | null => {
|
||||
return {
|
||||
endpoint:
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
`/jsmpeg/${cameraConfig.frigate.camera_name}`,
|
||||
sign: true,
|
||||
};
|
||||
};
|
||||
|
||||
const ui = getUIEndpoint();
|
||||
const go2rtc = getDefaultGo2RTCEndpoint(cameraConfig, {
|
||||
url:
|
||||
cameraConfig.go2rtc?.url ??
|
||||
// go2rtc is exposed by the Frigate integration under the (slightly
|
||||
// misleading) 'mse' path, even though that path can serve all go2rtc
|
||||
// modes.
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}/mse`,
|
||||
stream: cameraConfig.go2rtc?.stream ?? cameraConfig.frigate.camera_name,
|
||||
});
|
||||
const jsmpeg = getJSMPEG();
|
||||
|
||||
return {
|
||||
...super.getCameraEndpoints(cameraConfig, context),
|
||||
...(ui && { ui: ui }),
|
||||
...(go2rtc && { go2rtc: go2rtc }),
|
||||
...(jsmpeg && { jsmpeg: jsmpeg }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,9 @@ import { Endpoint } from '../../types';
|
||||
import { ViewMedia } from '../../view/item';
|
||||
import { ViewItemCapabilities } from '../../view/types';
|
||||
import { Camera } from '../camera';
|
||||
import { Capabilities } from '../capabilities';
|
||||
import { CameraManagerEngine } from '../engine';
|
||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||
import {
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraEventCallback,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraQuery,
|
||||
@@ -33,7 +30,6 @@ import {
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
} from '../types';
|
||||
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
|
||||
import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint';
|
||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||
|
||||
export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
@@ -53,32 +49,22 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
}
|
||||
|
||||
public async createCamera(
|
||||
_hass: HomeAssistant,
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<Camera> {
|
||||
return await new Camera(cameraConfig, this, {
|
||||
capabilities: new Capabilities(
|
||||
{
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
'remote-control-entity': true,
|
||||
clips: false,
|
||||
live: true,
|
||||
menu: true,
|
||||
recordings: false,
|
||||
seek: false,
|
||||
snapshots: false,
|
||||
substream: true,
|
||||
trigger: true,
|
||||
eventCallback: this._eventCallback,
|
||||
}).initialize({
|
||||
hass,
|
||||
stateWatcher: this._stateWatcher,
|
||||
capabilityOptions: {
|
||||
raw: {
|
||||
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
|
||||
},
|
||||
{
|
||||
disable: cameraConfig.capabilities?.disable,
|
||||
disableExcept: cameraConfig.capabilities?.disable_except,
|
||||
},
|
||||
),
|
||||
eventCallback: this._eventCallback,
|
||||
}).initialize({ stateWatcher: this._stateWatcher });
|
||||
disable: cameraConfig.capabilities?.disable,
|
||||
disableExcept: cameraConfig.capabilities?.disable_except,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public generateDefaultEventQuery(
|
||||
@@ -213,25 +199,4 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
public getMediaCapabilities(_media: ViewMedia): ViewItemCapabilities | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
public getCameraEndpoints(
|
||||
cameraConfig: CameraConfig,
|
||||
_context?: CameraEndpointsContext,
|
||||
): CameraEndpoints | null {
|
||||
const getWebRTCCard = (): Endpoint | null => {
|
||||
// The user may override this in their webrtc_card configuration.
|
||||
const endpoint = cameraConfig.camera_entity ? cameraConfig.camera_entity : null;
|
||||
return endpoint ? { endpoint: endpoint } : null;
|
||||
};
|
||||
|
||||
const go2rtc = getDefaultGo2RTCEndpoint(cameraConfig);
|
||||
const webrtcCard = getWebRTCCard();
|
||||
|
||||
return go2rtc || webrtcCard
|
||||
? {
|
||||
...(go2rtc && { go2rtc: go2rtc }),
|
||||
...(webrtcCard && { webrtcCard: webrtcCard }),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,12 +752,7 @@ export class CameraManager {
|
||||
cameraID: string,
|
||||
context?: CameraEndpointsContext,
|
||||
): CameraEndpoints | null {
|
||||
const cameraConfig = this._store.getCameraConfig(cameraID);
|
||||
const engine = this._store.getEngineForCameraID(cameraID);
|
||||
if (!cameraConfig || !engine) {
|
||||
return null;
|
||||
}
|
||||
return engine.getCameraEndpoints(cameraConfig, context);
|
||||
return this._store.getCamera(cameraID)?.getEndpoints(context) ?? null;
|
||||
}
|
||||
|
||||
public getCameraMetadata(cameraID: string): CameraManagerCameraMetadata | null {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { CapabilitiesRaw, Endpoint } from '../../types';
|
||||
import { CameraInitializationOptions } from '../camera';
|
||||
import { EntityCamera } from '../entity-camera';
|
||||
import { CameraProxyConfig } from '../types';
|
||||
import { CameraEndpointsContext, CameraProxyConfig } from '../types';
|
||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||
|
||||
export class MotionEyeCamera extends EntityCamera {
|
||||
public getProxyConfig(): CameraProxyConfig {
|
||||
@@ -10,4 +13,22 @@ export class MotionEyeCamera extends EntityCamera {
|
||||
media: this._config.proxy.media === 'auto' ? true : this._config.proxy.media,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected override _getUIEndpoint(_context?: CameraEndpointsContext): Endpoint | null {
|
||||
return this._config.motioneye?.url ? { endpoint: this._config.motioneye.url } : null;
|
||||
}
|
||||
|
||||
protected async _getRawCapabilities(
|
||||
options: CameraInitializationOptions,
|
||||
): Promise<CapabilitiesRaw> {
|
||||
const ptz = getPTZCapabilitiesFromCameraConfig(this.getConfig());
|
||||
|
||||
return {
|
||||
...(await super._getRawCapabilities(options)),
|
||||
clips: true,
|
||||
snapshots: true,
|
||||
...(ptz && { ptz }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,18 +14,14 @@ import {
|
||||
import { BrowseMediaStep, BrowseMediaTarget } from '../../ha/browse-media/walker';
|
||||
import { isMediaWithinDates } from '../../ha/browse-media/within-dates';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { Endpoint } from '../../types';
|
||||
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||
import { ViewMedia } from '../../view/item';
|
||||
import { EntityCamera } from '../entity-camera';
|
||||
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
||||
import { Camera } from '../camera';
|
||||
import { Capabilities } from '../capabilities';
|
||||
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
||||
import { EntityCamera } from '../entity-camera';
|
||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||
import {
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraManagerCameraMetadata,
|
||||
Engine,
|
||||
EngineOptions,
|
||||
@@ -39,7 +35,6 @@ import {
|
||||
QueryResultsType,
|
||||
QueryReturnType,
|
||||
} from '../types';
|
||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||
import { MotionEyeCamera } from './camera';
|
||||
import { MotionEyeEventQueryResults } from './types';
|
||||
|
||||
@@ -76,26 +71,6 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<Camera> {
|
||||
const camera = new MotionEyeCamera(cameraConfig, this, {
|
||||
capabilities: new Capabilities(
|
||||
{
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
'remote-control-entity': true,
|
||||
clips: true,
|
||||
live: true,
|
||||
menu: true,
|
||||
recordings: false,
|
||||
seek: false,
|
||||
snapshots: true,
|
||||
substream: true,
|
||||
trigger: true,
|
||||
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
|
||||
},
|
||||
{
|
||||
disable: cameraConfig.capabilities?.disable,
|
||||
disableExcept: cameraConfig.capabilities?.disable_except,
|
||||
},
|
||||
),
|
||||
eventCallback: this._eventCallback,
|
||||
});
|
||||
return await camera.initialize({
|
||||
@@ -423,22 +398,4 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
||||
engineIcon: 'motioneye',
|
||||
};
|
||||
}
|
||||
|
||||
public getCameraEndpoints(
|
||||
cameraConfig: CameraConfig,
|
||||
context?: CameraEndpointsContext,
|
||||
): CameraEndpoints | null {
|
||||
const getUIEndpoint = (): Endpoint | null => {
|
||||
return cameraConfig.motioneye?.url
|
||||
? {
|
||||
endpoint: cameraConfig.motioneye.url,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
const ui = getUIEndpoint();
|
||||
return {
|
||||
...super.getCameraEndpoints(cameraConfig, context),
|
||||
...(ui && { ui: ui }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
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 { DeviceRegistryManager } from '../../ha/registry/device/index';
|
||||
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { PTZCapabilities, PTZMovementType } from '../../types';
|
||||
import {
|
||||
CapabilitiesRaw,
|
||||
Endpoint,
|
||||
PTZCapabilities,
|
||||
PTZMovementType,
|
||||
} from '../../types';
|
||||
import { createSelectOptionAction } from '../../utils/action.js';
|
||||
import { Camera, CameraInitializationOptions } from '../camera';
|
||||
import { Capabilities } from '../capabilities';
|
||||
import { EntityCamera } from '../entity-camera';
|
||||
import { CameraInitializationError } from '../error';
|
||||
import { CameraProxyConfig } from '../types';
|
||||
import { CameraEndpointsContext, CameraProxyConfig } from '../types';
|
||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||
|
||||
// Reolink channels are zero indexed.
|
||||
@@ -20,7 +23,6 @@ const REOLINK_DEFAULT_CHANNEL = 0;
|
||||
interface ReolinkCameraInitializationOptions extends CameraInitializationOptions {
|
||||
entityRegistryManager: EntityRegistryManager;
|
||||
deviceRegistryManager: DeviceRegistryManager;
|
||||
hass: HomeAssistant;
|
||||
}
|
||||
|
||||
class ReolinkInitializationError extends CameraInitializationError {}
|
||||
@@ -50,15 +52,14 @@ export class ReolinkCamera extends EntityCamera {
|
||||
// Entities used for PTZ control.
|
||||
protected _ptzEntities: PTZEntities | null = null;
|
||||
|
||||
/**
|
||||
* Reolink cameras require additional options not present in the base class
|
||||
* initialization options, so this ~empty method is used to expand the type
|
||||
* expectations. Without this, callers cannot specify objects (e.g. the device
|
||||
* registry) without TypeScript errors.
|
||||
*/
|
||||
public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> {
|
||||
await super.initialize(options);
|
||||
await this._initializeChannel(options.hass, options.deviceRegistryManager);
|
||||
await this._initializeCapabilities(
|
||||
options.hass,
|
||||
options.entityRegistryManager,
|
||||
options.stateWatcher,
|
||||
);
|
||||
return this;
|
||||
return super.initialize(options);
|
||||
}
|
||||
|
||||
protected async _getChannelFromConfigurationURL(
|
||||
@@ -135,47 +136,37 @@ export class ReolinkCamera extends EntityCamera {
|
||||
this._reolinkCameraUID = reolinkCameraUID;
|
||||
}
|
||||
|
||||
protected async _initializeCapabilities(
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryManager,
|
||||
stateWatcher: StateWatcherSubscriptionInterface,
|
||||
protected async _initialize(
|
||||
options: ReolinkCameraInitializationOptions,
|
||||
): Promise<void> {
|
||||
const config = this.getConfig();
|
||||
const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig());
|
||||
this._ptzEntities = await this._getPTZEntities(hass, entityRegistry);
|
||||
const reolinkPTZCapabilities = this._ptzEntities
|
||||
? this._entitiesToCapabilities(hass, this._ptzEntities)
|
||||
await this._initializeChannel(options.hass, options.deviceRegistryManager);
|
||||
this._ptzEntities = await this._getPTZEntities(
|
||||
options.hass,
|
||||
options.entityRegistryManager,
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected override _getUIEndpoint(_context?: CameraEndpointsContext): Endpoint | null {
|
||||
return this._config.reolink?.url ? { endpoint: this._config.reolink.url } : null;
|
||||
}
|
||||
|
||||
protected async _getRawCapabilities(
|
||||
options: ReolinkCameraInitializationOptions,
|
||||
): Promise<CapabilitiesRaw> {
|
||||
const configPTZ = getPTZCapabilitiesFromCameraConfig(this.getConfig());
|
||||
const reolinkPTZ = this._ptzEntities
|
||||
? this._entitiesToCapabilities(options.hass, this._ptzEntities)
|
||||
: null;
|
||||
|
||||
const combinedPTZCapabilities: PTZCapabilities | null =
|
||||
configPTZCapabilities || reolinkPTZCapabilities
|
||||
? {
|
||||
...reolinkPTZCapabilities,
|
||||
...configPTZCapabilities,
|
||||
}
|
||||
: null;
|
||||
const combinedPTZ: PTZCapabilities | null =
|
||||
configPTZ || reolinkPTZ ? { ...reolinkPTZ, ...configPTZ } : null;
|
||||
|
||||
this._capabilities = new Capabilities(
|
||||
{
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
'remote-control-entity': true,
|
||||
clips: true,
|
||||
live: true,
|
||||
menu: true,
|
||||
recordings: false,
|
||||
seek: false,
|
||||
snapshots: false,
|
||||
substream: true,
|
||||
trigger: true,
|
||||
...(combinedPTZCapabilities && { ptz: combinedPTZCapabilities }),
|
||||
},
|
||||
{
|
||||
disable: config.capabilities?.disable,
|
||||
disableExcept: config.capabilities?.disable_except,
|
||||
},
|
||||
);
|
||||
this._subscribeBasedOnCapabilities(stateWatcher);
|
||||
return {
|
||||
...(await super._getRawCapabilities(options)),
|
||||
clips: true,
|
||||
...(combinedPTZ && { ptz: combinedPTZ }),
|
||||
};
|
||||
}
|
||||
|
||||
protected _entitiesToCapabilities(
|
||||
|
||||
@@ -18,7 +18,6 @@ import { DeviceRegistryManager } from '../../ha/registry/device';
|
||||
import { EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { Endpoint } from '../../types';
|
||||
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||
import { ViewMedia } from '../../view/item';
|
||||
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
||||
@@ -26,8 +25,6 @@ import { Camera } from '../camera';
|
||||
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||
import {
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraEventCallback,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerRequestCache,
|
||||
@@ -420,22 +417,4 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||
engineIcon: 'reolink',
|
||||
};
|
||||
}
|
||||
|
||||
public getCameraEndpoints(
|
||||
cameraConfig: CameraConfig,
|
||||
context?: CameraEndpointsContext,
|
||||
): CameraEndpoints | null {
|
||||
const getUIEndpoint = (): Endpoint | null => {
|
||||
return cameraConfig.reolink?.url
|
||||
? {
|
||||
endpoint: cameraConfig.reolink.url,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
const ui = getUIEndpoint();
|
||||
return {
|
||||
...super.getCameraEndpoints(cameraConfig, context),
|
||||
...(ui && { ui: ui }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
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 { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { PTZCapabilities, PTZMovementType } from '../../types';
|
||||
import { Camera } from '../camera';
|
||||
import { Capabilities } from '../capabilities';
|
||||
import { CapabilitiesRaw, PTZCapabilities, PTZMovementType } from '../../types';
|
||||
import { EntityCamera, EntityCameraInitializationOptions } from '../entity-camera';
|
||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||
|
||||
@@ -22,57 +19,30 @@ type PTZEntity = keyof PTZEntities;
|
||||
export class TPLinkCamera extends EntityCamera {
|
||||
protected _ptzEntities: PTZEntities | null = null;
|
||||
|
||||
public async initialize(options: TPLinkCameraInitializationOptions): Promise<Camera> {
|
||||
await super.initialize(options);
|
||||
await this._initializeCapabilities(
|
||||
protected async _initialize(
|
||||
options: TPLinkCameraInitializationOptions,
|
||||
): Promise<void> {
|
||||
this._ptzEntities = await this._getPTZEntities(
|
||||
options.hass,
|
||||
options.entityRegistryManager,
|
||||
options.stateWatcher,
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
protected async _initializeCapabilities(
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryManager,
|
||||
stateWatcher: StateWatcherSubscriptionInterface,
|
||||
): Promise<void> {
|
||||
const config = this.getConfig();
|
||||
const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(config);
|
||||
this._ptzEntities = await this._getPTZEntities(hass, entityRegistry);
|
||||
const tplinkPTZCapabilities = this._ptzEntities
|
||||
protected async _getRawCapabilities(
|
||||
options: TPLinkCameraInitializationOptions,
|
||||
): Promise<CapabilitiesRaw> {
|
||||
const configPTZ = getPTZCapabilitiesFromCameraConfig(this.getConfig());
|
||||
const tplinkPTZ = this._ptzEntities
|
||||
? this._entitiesToCapabilities(this._ptzEntities)
|
||||
: null;
|
||||
|
||||
const combinedPTZCapabilities: PTZCapabilities | null =
|
||||
configPTZCapabilities || tplinkPTZCapabilities
|
||||
? {
|
||||
...tplinkPTZCapabilities,
|
||||
...configPTZCapabilities,
|
||||
}
|
||||
: null;
|
||||
const combinedPTZ: PTZCapabilities | null =
|
||||
configPTZ || tplinkPTZ ? { ...tplinkPTZ, ...configPTZ } : null;
|
||||
|
||||
this._capabilities = new Capabilities(
|
||||
{
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
'remote-control-entity': true,
|
||||
clips: false,
|
||||
live: true,
|
||||
menu: true,
|
||||
recordings: false,
|
||||
seek: false,
|
||||
snapshots: false,
|
||||
substream: true,
|
||||
trigger: true,
|
||||
...(combinedPTZCapabilities && { ptz: combinedPTZCapabilities }),
|
||||
},
|
||||
{
|
||||
disable: config.capabilities?.disable,
|
||||
disableExcept: config.capabilities?.disable_except,
|
||||
},
|
||||
);
|
||||
this._subscribeBasedOnCapabilities(stateWatcher);
|
||||
return {
|
||||
...(await super._getRawCapabilities(options)),
|
||||
...(combinedPTZ && { ptz: combinedPTZ }),
|
||||
};
|
||||
}
|
||||
|
||||
protected async _getPTZEntities(
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { CameraConfig } from '../../config/schema/cameras';
|
||||
import { Endpoint } from '../../types';
|
||||
|
||||
export const getDefaultGo2RTCEndpoint = (
|
||||
cameraConfig: CameraConfig,
|
||||
options?: {
|
||||
url?: string;
|
||||
stream?: string;
|
||||
},
|
||||
): Endpoint | null => {
|
||||
const url = options?.url ?? cameraConfig.go2rtc?.url;
|
||||
const stream = options?.stream ?? cameraConfig.go2rtc?.stream;
|
||||
|
||||
if (!url || !stream) {
|
||||
return null;
|
||||
}
|
||||
const endpoint = `${url}/api/ws?src=${stream}`;
|
||||
|
||||
return {
|
||||
endpoint: endpoint,
|
||||
|
||||
// Only sign the endpoint if it's local to HA.
|
||||
sign: endpoint.startsWith('/'),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
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 GO2RTC_METADATA_TIMEOUT_SECONDS = 2;
|
||||
|
||||
const getGo2RTCStreamMetadata = async (
|
||||
hass: HomeAssistant,
|
||||
endpoint: Endpoint,
|
||||
): Promise<Go2RTCStreamInfo | null> => {
|
||||
try {
|
||||
return await homeAssistantSignAndFetch(hass, endpoint, go2RTCStreamInfoSchema, {
|
||||
timeoutSeconds: GO2RTC_METADATA_TIMEOUT_SECONDS,
|
||||
});
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const streamSupports2WayAudio = (streamInfo: Go2RTCStreamInfo | null): boolean => {
|
||||
if (!streamInfo?.producers) {
|
||||
return false;
|
||||
}
|
||||
return streamInfo.producers.some(
|
||||
(producer) =>
|
||||
producer.medias?.some(
|
||||
(media) =>
|
||||
media.includes('audio') &&
|
||||
(media.includes('sendonly') || media.includes('sendrecv')),
|
||||
) ?? false,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch go2rtc metadata and determine if the stream supports 2-way audio.
|
||||
* Handles proxy transformation if proxy config requires it.
|
||||
* Returns false if the endpoint is not available or fetch fails.
|
||||
*
|
||||
* Note: Caller is responsible for checking if live_provider is 'go2rtc' before calling.
|
||||
*
|
||||
* @param hass Home Assistant instance.
|
||||
* @param go2rtcMetadataEndpoint The go2rtc metadata endpoint.
|
||||
* @param proxyConfig The camera's proxy configuration for live streams.
|
||||
* @returns True if supports 2-way audio, false otherwise.
|
||||
*/
|
||||
export const supports2WayAudio = async (
|
||||
hass: HomeAssistant,
|
||||
go2rtcMetadataEndpoint?: Endpoint | null,
|
||||
proxyConfig?: CameraProxyConfig,
|
||||
): Promise<boolean> => {
|
||||
if (!go2rtcMetadataEndpoint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endpoint = await createProxiedEndpointIfNecessary(
|
||||
hass,
|
||||
go2rtcMetadataEndpoint,
|
||||
proxyConfig,
|
||||
{ context: 'live', openLimit: 1 },
|
||||
);
|
||||
|
||||
const streamInfo = await getGo2RTCStreamMetadata(hass, endpoint);
|
||||
return streamSupports2WayAudio(streamInfo);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { CameraConfig } from '../../../config/schema/cameras';
|
||||
import { Endpoint } from '../../../types';
|
||||
|
||||
type EndpointOptions = {
|
||||
url?: string;
|
||||
stream?: string;
|
||||
};
|
||||
|
||||
const buildGo2RTCEndpoint = (
|
||||
cameraConfig: CameraConfig,
|
||||
pathBuilder: (url: string, stream: string) => string,
|
||||
options?: EndpointOptions,
|
||||
): Endpoint | null => {
|
||||
const url = options?.url ?? cameraConfig.go2rtc?.url;
|
||||
const stream = options?.stream ?? cameraConfig.go2rtc?.stream;
|
||||
|
||||
if (!url || !stream) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = pathBuilder(url, stream);
|
||||
return {
|
||||
endpoint,
|
||||
// Only sign the endpoint if it's local to HA.
|
||||
sign: endpoint.startsWith('/'),
|
||||
};
|
||||
};
|
||||
|
||||
export const getGo2RTCStreamEndpoint = (
|
||||
cameraConfig: CameraConfig,
|
||||
options?: EndpointOptions,
|
||||
): Endpoint | null => {
|
||||
return buildGo2RTCEndpoint(
|
||||
cameraConfig,
|
||||
(url, stream) => `${url}/api/ws?src=${stream}`,
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
export const getGo2RTCMetadataEndpoint = (
|
||||
cameraConfig: CameraConfig,
|
||||
options?: EndpointOptions,
|
||||
): Endpoint | null => {
|
||||
return buildGo2RTCEndpoint(
|
||||
cameraConfig,
|
||||
// Use probe parameters to trigger active stream detection.
|
||||
// Without these, go2rtc only returns static config without producer medias.
|
||||
(url, stream) => `${url}/api/streams?src=${stream}&video=all&audio=allµphone`,
|
||||
options,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const go2RTCProducerSchema = z.object({
|
||||
medias: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Zod schema for Go2RTC stream information. Schema only covers the minimum
|
||||
* required by the card.
|
||||
* Response from `/api/streams?src=${stream}&video=all&audio=allµphone`
|
||||
*/
|
||||
export const go2RTCStreamInfoSchema = z.object({
|
||||
producers: z.array(go2RTCProducerSchema).optional(),
|
||||
});
|
||||
|
||||
export type Go2RTCStreamInfo = z.infer<typeof go2RTCStreamInfoSchema>;
|
||||
Reference in New Issue
Block a user