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:
Dermot Duffy
2025-12-20 13:06:09 -08:00
committed by GitHub
parent 588ed6c98a
commit 1e821f09c6
47 changed files with 2240 additions and 958 deletions
+120 -6
View File
@@ -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:
-7
View File
@@ -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;
}
-2
View File
@@ -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 {
+135 -56
View File
@@ -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 -87
View File
@@ -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 }),
};
}
}
+11 -46
View File
@@ -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;
}
}
+1 -6
View File
@@ -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 {
+22 -1
View File
@@ -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 }),
};
}
}
+41 -50
View File
@@ -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 }),
};
}
}
+16 -46
View File
@@ -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('/'),
};
};
+69
View File
@@ -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&microphone`,
options,
);
};
+16
View File
@@ -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&microphone`
*/
export const go2RTCStreamInfoSchema = z.object({
producers: z.array(go2RTCProducerSchema).optional(),
});
export type Go2RTCStreamInfo = z.infer<typeof go2RTCStreamInfoSchema>;