Files
advanced-camera-card/src/camera-manager/camera.ts
T
Dermot Duffy 1e821f09c6 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
```
2025-12-20 13:06:09 -08:00

242 lines
7.3 KiB
TypeScript

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 { isTriggeredState } from '../ha/is-triggered-state';
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 {
CameraEndpoints,
CameraEndpointsContext,
CameraEventCallback,
CameraProxyConfig,
} from './types';
import {
getGo2RTCMetadataEndpoint,
getGo2RTCStreamEndpoint,
} from './utils/go2rtc/endpoint';
import { getConfiguredPTZAction } from './utils/ptz';
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 {
protected _config: CameraConfig;
protected _engine: CameraManagerEngine;
protected _capabilities?: Capabilities;
protected _eventCallback?: CameraEventCallback;
protected _destroyCallbacks: DestroyCallback[] = [];
constructor(
config: CameraConfig,
engine: CameraManagerEngine,
options?: {
eventCallback?: CameraEventCallback;
capabilities?: Capabilities;
},
) {
this._config = config;
this._engine = engine;
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());
}
public getConfig(): CameraConfig {
return this._config;
}
public setID(cameraID: string): void {
this._config.id = cameraID;
}
public getID(): string {
if (this._config.id) {
return this._config.id;
}
throw new CameraNoIDError(localize('error.no_camera_id'));
}
public getEngine(): CameraManagerEngine {
return this._engine;
}
public getCapabilities(): Capabilities | null {
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:
this._config.proxy.live === 'auto'
? // Live is proxied if the live provider is go2rtc and if a go2rtc
// URL is manually set.
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 async executePTZAction(
executor: ActionsExecutor,
action: PTZAction,
options?: {
phase?: PTZActionPhase;
preset?: string;
},
): Promise<boolean> {
const configuredAction = getConfiguredPTZAction(this.getConfig(), action, options);
if (configuredAction) {
await executor.executeActions({ actions: configuredAction });
return true;
}
return false;
}
protected _stateChangeHandler = (difference: HassStateDifference): void => {
this._eventCallback?.({
cameraID: this.getID(),
type: isTriggeredState(difference.newState.state) ? 'new' : 'end',
});
};
protected _onDestroy(callback: DestroyCallback): void {
this._destroyCallbacks.push(callback);
}
protected _subscribeBasedOnCapabilities(
stateWatcher: StateWatcherSubscriptionInterface,
): void {
if (this._capabilities?.has('trigger')) {
stateWatcher.unsubscribe(this._stateChangeHandler);
stateWatcher.subscribe(this._stateChangeHandler, this._config.triggers.entities);
}
}
}