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: