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:
@@ -66,6 +66,7 @@ cameras:
|
|||||||
| `snapshots` | Snapshots can be fetched from the camera. |
|
| `snapshots` | Snapshots can be fetched from the camera. |
|
||||||
| `substream` | The camera can be used as a substream on another camera. |
|
| `substream` | The camera can be used as a substream on another camera. |
|
||||||
| `trigger` | The camera can be triggered. |
|
| `trigger` | The camera can be triggered. |
|
||||||
|
| `2-way-audio` | The camera can be used for 2-way audio. |
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> If using a camera only as a `substream`, don't forget to keep both the
|
> If using a camera only as a `substream`, don't forget to keep both the
|
||||||
@@ -482,6 +483,7 @@ cameras:
|
|||||||
- snapshots
|
- snapshots
|
||||||
- substream
|
- substream
|
||||||
- trigger
|
- trigger
|
||||||
|
- 2-way-audio
|
||||||
disable:
|
disable:
|
||||||
# Capabilities to selectively disable.
|
# Capabilities to selectively disable.
|
||||||
- camera_entity: camera.rotated
|
- camera_entity: camera.rotated
|
||||||
|
|||||||
@@ -3,17 +3,41 @@ import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state
|
|||||||
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
|
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
|
||||||
import { CameraConfig } from '../config/schema/cameras';
|
import { CameraConfig } from '../config/schema/cameras';
|
||||||
import { isTriggeredState } from '../ha/is-triggered-state';
|
import { isTriggeredState } from '../ha/is-triggered-state';
|
||||||
import { HassStateDifference } from '../ha/types';
|
import { HassStateDifference, HomeAssistant } from '../ha/types';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
|
import { CapabilitiesRaw, CapabilityKey, Endpoint } from '../types';
|
||||||
|
import { liveProviderSupports2WayAudio } from '../utils/live-provider';
|
||||||
import { Capabilities } from './capabilities';
|
import { Capabilities } from './capabilities';
|
||||||
import { CameraManagerEngine } from './engine';
|
import { CameraManagerEngine } from './engine';
|
||||||
import { CameraNoIDError } from './error';
|
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';
|
import { getConfiguredPTZAction } from './utils/ptz';
|
||||||
|
|
||||||
export interface CameraInitializationOptions {
|
interface CapabilityOptions {
|
||||||
stateWatcher: StateWatcherSubscriptionInterface;
|
// 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>;
|
type DestroyCallback = () => void | Promise<void>;
|
||||||
|
|
||||||
export class Camera {
|
export class Camera {
|
||||||
@@ -27,22 +51,73 @@ export class Camera {
|
|||||||
config: CameraConfig,
|
config: CameraConfig,
|
||||||
engine: CameraManagerEngine,
|
engine: CameraManagerEngine,
|
||||||
options?: {
|
options?: {
|
||||||
capabilities?: Capabilities;
|
|
||||||
eventCallback?: CameraEventCallback;
|
eventCallback?: CameraEventCallback;
|
||||||
|
capabilities?: Capabilities;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
this._config = config;
|
this._config = config;
|
||||||
this._engine = engine;
|
this._engine = engine;
|
||||||
this._capabilities = options?.capabilities;
|
|
||||||
this._eventCallback = options?.eventCallback;
|
this._eventCallback = options?.eventCallback;
|
||||||
|
this._capabilities = options?.capabilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(options: CameraInitializationOptions): Promise<Camera> {
|
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._subscribeBasedOnCapabilities(options.stateWatcher);
|
||||||
this._onDestroy(() => options.stateWatcher.unsubscribe(this._stateChangeHandler));
|
this._onDestroy(() => options.stateWatcher.unsubscribe(this._stateChangeHandler));
|
||||||
return this;
|
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> {
|
public async destroy(): Promise<void> {
|
||||||
this._destroyCallbacks.forEach((callback) => callback());
|
this._destroyCallbacks.forEach((callback) => callback());
|
||||||
}
|
}
|
||||||
@@ -70,6 +145,45 @@ export class Camera {
|
|||||||
return this._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 {
|
public getProxyConfig(): CameraProxyConfig {
|
||||||
return {
|
return {
|
||||||
live:
|
live:
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { ViewItemCapabilities } from '../view/types';
|
|||||||
import { Camera } from './camera';
|
import { Camera } from './camera';
|
||||||
import { CameraManagerReadOnlyConfigStore } from './store';
|
import { CameraManagerReadOnlyConfigStore } from './store';
|
||||||
import {
|
import {
|
||||||
CameraEndpoints,
|
|
||||||
CameraEndpointsContext,
|
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
CameraQuery,
|
CameraQuery,
|
||||||
Engine,
|
Engine,
|
||||||
@@ -122,9 +120,4 @@ export interface CameraManagerEngine {
|
|||||||
): CameraManagerCameraMetadata;
|
): CameraManagerCameraMetadata;
|
||||||
|
|
||||||
getMediaCapabilities(media: ViewMedia): ViewItemCapabilities | null;
|
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 { Entity, EntityRegistryManager } from '../ha/registry/entity/types';
|
||||||
import { HomeAssistant } from '../ha/types';
|
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import { Camera, CameraInitializationOptions } from './camera';
|
import { Camera, CameraInitializationOptions } from './camera';
|
||||||
import { CameraInitializationError } from './error';
|
import { CameraInitializationError } from './error';
|
||||||
@@ -7,7 +6,6 @@ import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
|
|||||||
|
|
||||||
export interface EntityCameraInitializationOptions extends CameraInitializationOptions {
|
export interface EntityCameraInitializationOptions extends CameraInitializationOptions {
|
||||||
entityRegistryManager: EntityRegistryManager;
|
entityRegistryManager: EntityRegistryManager;
|
||||||
hass: HomeAssistant;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EntityCamera extends Camera {
|
export class EntityCamera extends Camera {
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
|
import { format } from 'date-fns';
|
||||||
import { uniq } from 'lodash-es';
|
import { uniq } from 'lodash-es';
|
||||||
import { ActionsExecutor } from '../../card-controller/actions/types';
|
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 { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
|
||||||
import { CameraConfig } from '../../config/schema/cameras';
|
import { CameraConfig } from '../../config/schema/cameras';
|
||||||
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { localize } from '../../localize/localize';
|
import { localize } from '../../localize/localize';
|
||||||
import { PTZCapabilities, PTZMovementType } from '../../types';
|
import {
|
||||||
|
CapabilitiesRaw,
|
||||||
|
Endpoint,
|
||||||
|
PTZCapabilities,
|
||||||
|
PTZMovementType,
|
||||||
|
} from '../../types';
|
||||||
import { errorToConsole } from '../../utils/basic';
|
import { errorToConsole } from '../../utils/basic';
|
||||||
import { Camera, CameraInitializationOptions } from '../camera';
|
import { Camera, CameraInitializationOptions } from '../camera';
|
||||||
import { Capabilities } from '../capabilities';
|
|
||||||
import { CameraManagerEngine } from '../engine';
|
|
||||||
import { CameraInitializationError } from '../error';
|
import { CameraInitializationError } from '../error';
|
||||||
import { CameraEventCallback } from '../types';
|
import { CameraEndpoints, CameraEndpointsContext } from '../types';
|
||||||
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
|
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
|
||||||
|
import {
|
||||||
|
getGo2RTCMetadataEndpoint,
|
||||||
|
getGo2RTCStreamEndpoint,
|
||||||
|
} from '../utils/go2rtc/endpoint';
|
||||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||||
import {
|
import {
|
||||||
FrigateEventWatcherRequest,
|
FrigateEventWatcherRequest,
|
||||||
@@ -27,8 +34,6 @@ const CAMERA_BIRDSEYE = 'birdseye' as const;
|
|||||||
interface FrigateCameraInitializationOptions extends CameraInitializationOptions {
|
interface FrigateCameraInitializationOptions extends CameraInitializationOptions {
|
||||||
entityRegistryManager: EntityRegistryManager;
|
entityRegistryManager: EntityRegistryManager;
|
||||||
frigateEventWatcher: FrigateEventWatcherSubscriptionInterface;
|
frigateEventWatcher: FrigateEventWatcherSubscriptionInterface;
|
||||||
hass: HomeAssistant;
|
|
||||||
stateWatcher: StateWatcherSubscriptionInterface;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
|
export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
|
||||||
@@ -36,26 +41,15 @@ export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class FrigateCamera extends Camera {
|
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> {
|
public async initialize(options: FrigateCameraInitializationOptions): Promise<Camera> {
|
||||||
await this._initializeConfig(options.hass, options.entityRegistryManager);
|
await this._initializeConfig(options.hass, options.entityRegistryManager);
|
||||||
await this._initializeCapabilities(options.hass, options.stateWatcher);
|
await super.initialize(options);
|
||||||
|
|
||||||
if (this._capabilities?.has('trigger')) {
|
if (this._capabilities?.has('trigger')) {
|
||||||
await this._subscribeToEvents(options.hass, options.frigateEventWatcher);
|
await this._subscribeToEvents(options.hass, options.frigateEventWatcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await super.initialize(options);
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async executePTZAction(
|
public async executePTZAction(
|
||||||
@@ -170,45 +164,27 @@ export class FrigateCamera extends Camera {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _initializeCapabilities(
|
protected async _getRawCapabilities(
|
||||||
hass: HomeAssistant,
|
options: FrigateCameraInitializationOptions,
|
||||||
stateWatcher: StateWatcherSubscriptionInterface,
|
): Promise<CapabilitiesRaw> {
|
||||||
): Promise<void> {
|
const base = await super._getRawCapabilities(options);
|
||||||
const config = this.getConfig();
|
const config = this.getConfig();
|
||||||
|
|
||||||
const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig());
|
const frigatePTZ = await this._getPTZCapabilities(options.hass, config);
|
||||||
const frigatePTZCapabilities = await this._getPTZCapabilities(hass, config);
|
const configPTZ = getPTZCapabilitiesFromCameraConfig(config);
|
||||||
|
const combinedPTZ: PTZCapabilities | null =
|
||||||
const combinedPTZCapabilities: PTZCapabilities | null =
|
configPTZ || frigatePTZ ? { ...frigatePTZ, ...configPTZ } : null;
|
||||||
configPTZCapabilities || frigatePTZCapabilities
|
|
||||||
? {
|
|
||||||
...frigatePTZCapabilities,
|
|
||||||
...configPTZCapabilities,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const birdseye = isBirdseye(config);
|
const birdseye = isBirdseye(config);
|
||||||
this._capabilities = new Capabilities(
|
return {
|
||||||
{
|
...base,
|
||||||
'favorite-events': !birdseye,
|
'favorite-events': !birdseye,
|
||||||
'favorite-recordings': false,
|
seek: !birdseye,
|
||||||
'remote-control-entity': true,
|
clips: !birdseye,
|
||||||
seek: !birdseye,
|
snapshots: !birdseye,
|
||||||
clips: !birdseye,
|
recordings: !birdseye,
|
||||||
snapshots: !birdseye,
|
...(combinedPTZ && { ptz: combinedPTZ }),
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _getFrigateCameraNameFromEntity(entity: Entity): string | null {
|
protected _getFrigateCameraNameFromEntity(entity: Entity): string | null {
|
||||||
@@ -225,6 +201,100 @@ export class FrigateCamera extends Camera {
|
|||||||
return null;
|
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(
|
protected async _getPTZCapabilities(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
@@ -372,6 +442,14 @@ export class FrigateCamera extends Camera {
|
|||||||
const clipChange = !ev.before.has_clip && ev.after.has_clip;
|
const clipChange = !ev.before.has_clip && ev.after.has_clip;
|
||||||
|
|
||||||
const config = this.getConfig();
|
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 (
|
if (
|
||||||
(config.frigate.zones?.length &&
|
(config.frigate.zones?.length &&
|
||||||
!config.frigate.zones.some((zone) => ev.after.current_zones.includes(zone))) ||
|
!config.frigate.zones.some((zone) => ev.after.current_zones.includes(zone))) ||
|
||||||
@@ -392,8 +470,9 @@ export class FrigateCamera extends Camera {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._eventCallback?.({
|
this._eventCallback?.({
|
||||||
|
cameraID,
|
||||||
|
|
||||||
fidelity: 'high',
|
fidelity: 'high',
|
||||||
cameraID: this.getID(),
|
|
||||||
type: ev.type,
|
type: ev.type,
|
||||||
// In cases where there are both clip and snapshot media, ensure to only
|
// In cases where there are both clip and snapshot media, ensure to only
|
||||||
// trigger on the media type that is allowed by the configuration.
|
// 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 { isEqual, orderBy, throttle, uniqWith } from 'lodash-es';
|
||||||
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
||||||
import { CameraConfig } from '../../config/schema/cameras';
|
import { CameraConfig } from '../../config/schema/cameras';
|
||||||
@@ -25,8 +25,6 @@ import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
|||||||
import { DateRange } from '../range';
|
import { DateRange } from '../range';
|
||||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||||
import {
|
import {
|
||||||
CameraEndpoints,
|
|
||||||
CameraEndpointsContext,
|
|
||||||
CameraEventCallback,
|
CameraEventCallback,
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
CameraManagerRequestCache,
|
CameraManagerRequestCache,
|
||||||
@@ -53,7 +51,6 @@ import {
|
|||||||
RecordingSegmentsQuery,
|
RecordingSegmentsQuery,
|
||||||
RecordingSegmentsQueryResultsMap,
|
RecordingSegmentsQueryResultsMap,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint';
|
|
||||||
import { FrigateCamera, isBirdseye } from './camera';
|
import { FrigateCamera, isBirdseye } from './camera';
|
||||||
import { FrigateEventWatcher } from './event-watcher';
|
import { FrigateEventWatcher } from './event-watcher';
|
||||||
import { FrigateViewMediaFactory } from './media';
|
import { FrigateViewMediaFactory } from './media';
|
||||||
@@ -920,87 +917,4 @@ export class FrigateCameraManagerEngine
|
|||||||
engineIcon: 'frigate',
|
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 { ViewMedia } from '../../view/item';
|
||||||
import { ViewItemCapabilities } from '../../view/types';
|
import { ViewItemCapabilities } from '../../view/types';
|
||||||
import { Camera } from '../camera';
|
import { Camera } from '../camera';
|
||||||
import { Capabilities } from '../capabilities';
|
|
||||||
import { CameraManagerEngine } from '../engine';
|
import { CameraManagerEngine } from '../engine';
|
||||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||||
import {
|
import {
|
||||||
CameraEndpoints,
|
|
||||||
CameraEndpointsContext,
|
|
||||||
CameraEventCallback,
|
CameraEventCallback,
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
CameraQuery,
|
CameraQuery,
|
||||||
@@ -33,7 +30,6 @@ import {
|
|||||||
RecordingSegmentsQueryResultsMap,
|
RecordingSegmentsQueryResultsMap,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
|
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
|
||||||
import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint';
|
|
||||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||||
|
|
||||||
export class GenericCameraManagerEngine implements CameraManagerEngine {
|
export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||||
@@ -53,32 +49,22 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async createCamera(
|
public async createCamera(
|
||||||
_hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
): Promise<Camera> {
|
): Promise<Camera> {
|
||||||
return await new Camera(cameraConfig, this, {
|
return await new Camera(cameraConfig, this, {
|
||||||
capabilities: new Capabilities(
|
eventCallback: this._eventCallback,
|
||||||
{
|
}).initialize({
|
||||||
'favorite-events': false,
|
hass,
|
||||||
'favorite-recordings': false,
|
stateWatcher: this._stateWatcher,
|
||||||
'remote-control-entity': true,
|
capabilityOptions: {
|
||||||
clips: false,
|
raw: {
|
||||||
live: true,
|
|
||||||
menu: true,
|
|
||||||
recordings: false,
|
|
||||||
seek: false,
|
|
||||||
snapshots: false,
|
|
||||||
substream: true,
|
|
||||||
trigger: true,
|
|
||||||
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
|
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
|
||||||
},
|
},
|
||||||
{
|
disable: cameraConfig.capabilities?.disable,
|
||||||
disable: cameraConfig.capabilities?.disable,
|
disableExcept: cameraConfig.capabilities?.disable_except,
|
||||||
disableExcept: cameraConfig.capabilities?.disable_except,
|
},
|
||||||
},
|
});
|
||||||
),
|
|
||||||
eventCallback: this._eventCallback,
|
|
||||||
}).initialize({ stateWatcher: this._stateWatcher });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public generateDefaultEventQuery(
|
public generateDefaultEventQuery(
|
||||||
@@ -213,25 +199,4 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
|||||||
public getMediaCapabilities(_media: ViewMedia): ViewItemCapabilities | null {
|
public getMediaCapabilities(_media: ViewMedia): ViewItemCapabilities | null {
|
||||||
return 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,
|
cameraID: string,
|
||||||
context?: CameraEndpointsContext,
|
context?: CameraEndpointsContext,
|
||||||
): CameraEndpoints | null {
|
): CameraEndpoints | null {
|
||||||
const cameraConfig = this._store.getCameraConfig(cameraID);
|
return this._store.getCamera(cameraID)?.getEndpoints(context) ?? null;
|
||||||
const engine = this._store.getEngineForCameraID(cameraID);
|
|
||||||
if (!cameraConfig || !engine) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return engine.getCameraEndpoints(cameraConfig, context);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCameraMetadata(cameraID: string): CameraManagerCameraMetadata | 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 { EntityCamera } from '../entity-camera';
|
||||||
import { CameraProxyConfig } from '../types';
|
import { CameraEndpointsContext, CameraProxyConfig } from '../types';
|
||||||
|
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||||
|
|
||||||
export class MotionEyeCamera extends EntityCamera {
|
export class MotionEyeCamera extends EntityCamera {
|
||||||
public getProxyConfig(): CameraProxyConfig {
|
public getProxyConfig(): CameraProxyConfig {
|
||||||
@@ -10,4 +13,22 @@ export class MotionEyeCamera extends EntityCamera {
|
|||||||
media: this._config.proxy.media === 'auto' ? true : this._config.proxy.media,
|
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 { BrowseMediaStep, BrowseMediaTarget } from '../../ha/browse-media/walker';
|
||||||
import { isMediaWithinDates } from '../../ha/browse-media/within-dates';
|
import { isMediaWithinDates } from '../../ha/browse-media/within-dates';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { Endpoint } from '../../types';
|
|
||||||
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||||
import { ViewMedia } from '../../view/item';
|
import { ViewMedia } from '../../view/item';
|
||||||
import { EntityCamera } from '../entity-camera';
|
|
||||||
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
||||||
import { Camera } from '../camera';
|
import { Camera } from '../camera';
|
||||||
import { Capabilities } from '../capabilities';
|
|
||||||
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
||||||
|
import { EntityCamera } from '../entity-camera';
|
||||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||||
import {
|
import {
|
||||||
CameraEndpoints,
|
|
||||||
CameraEndpointsContext,
|
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
Engine,
|
Engine,
|
||||||
EngineOptions,
|
EngineOptions,
|
||||||
@@ -39,7 +35,6 @@ import {
|
|||||||
QueryResultsType,
|
QueryResultsType,
|
||||||
QueryReturnType,
|
QueryReturnType,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
|
||||||
import { MotionEyeCamera } from './camera';
|
import { MotionEyeCamera } from './camera';
|
||||||
import { MotionEyeEventQueryResults } from './types';
|
import { MotionEyeEventQueryResults } from './types';
|
||||||
|
|
||||||
@@ -76,26 +71,6 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
|||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
): Promise<Camera> {
|
): Promise<Camera> {
|
||||||
const camera = new MotionEyeCamera(cameraConfig, this, {
|
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,
|
eventCallback: this._eventCallback,
|
||||||
});
|
});
|
||||||
return await camera.initialize({
|
return await camera.initialize({
|
||||||
@@ -423,22 +398,4 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
|||||||
engineIcon: 'motioneye',
|
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 { ActionsExecutor } from '../../card-controller/actions/types';
|
||||||
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
|
||||||
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
|
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
|
||||||
import { DeviceRegistryManager } from '../../ha/registry/device/index';
|
import { DeviceRegistryManager } from '../../ha/registry/device/index';
|
||||||
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { localize } from '../../localize/localize';
|
import { localize } from '../../localize/localize';
|
||||||
import { PTZCapabilities, PTZMovementType } from '../../types';
|
import {
|
||||||
|
CapabilitiesRaw,
|
||||||
|
Endpoint,
|
||||||
|
PTZCapabilities,
|
||||||
|
PTZMovementType,
|
||||||
|
} from '../../types';
|
||||||
import { createSelectOptionAction } from '../../utils/action.js';
|
import { createSelectOptionAction } from '../../utils/action.js';
|
||||||
import { Camera, CameraInitializationOptions } from '../camera';
|
import { Camera, CameraInitializationOptions } from '../camera';
|
||||||
import { Capabilities } from '../capabilities';
|
|
||||||
import { EntityCamera } from '../entity-camera';
|
import { EntityCamera } from '../entity-camera';
|
||||||
import { CameraInitializationError } from '../error';
|
import { CameraInitializationError } from '../error';
|
||||||
import { CameraProxyConfig } from '../types';
|
import { CameraEndpointsContext, CameraProxyConfig } from '../types';
|
||||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||||
|
|
||||||
// Reolink channels are zero indexed.
|
// Reolink channels are zero indexed.
|
||||||
@@ -20,7 +23,6 @@ const REOLINK_DEFAULT_CHANNEL = 0;
|
|||||||
interface ReolinkCameraInitializationOptions extends CameraInitializationOptions {
|
interface ReolinkCameraInitializationOptions extends CameraInitializationOptions {
|
||||||
entityRegistryManager: EntityRegistryManager;
|
entityRegistryManager: EntityRegistryManager;
|
||||||
deviceRegistryManager: DeviceRegistryManager;
|
deviceRegistryManager: DeviceRegistryManager;
|
||||||
hass: HomeAssistant;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ReolinkInitializationError extends CameraInitializationError {}
|
class ReolinkInitializationError extends CameraInitializationError {}
|
||||||
@@ -50,15 +52,14 @@ export class ReolinkCamera extends EntityCamera {
|
|||||||
// Entities used for PTZ control.
|
// Entities used for PTZ control.
|
||||||
protected _ptzEntities: PTZEntities | null = null;
|
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> {
|
public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> {
|
||||||
await super.initialize(options);
|
return super.initialize(options);
|
||||||
await this._initializeChannel(options.hass, options.deviceRegistryManager);
|
|
||||||
await this._initializeCapabilities(
|
|
||||||
options.hass,
|
|
||||||
options.entityRegistryManager,
|
|
||||||
options.stateWatcher,
|
|
||||||
);
|
|
||||||
return this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _getChannelFromConfigurationURL(
|
protected async _getChannelFromConfigurationURL(
|
||||||
@@ -135,47 +136,37 @@ export class ReolinkCamera extends EntityCamera {
|
|||||||
this._reolinkCameraUID = reolinkCameraUID;
|
this._reolinkCameraUID = reolinkCameraUID;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _initializeCapabilities(
|
protected async _initialize(
|
||||||
hass: HomeAssistant,
|
options: ReolinkCameraInitializationOptions,
|
||||||
entityRegistry: EntityRegistryManager,
|
|
||||||
stateWatcher: StateWatcherSubscriptionInterface,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const config = this.getConfig();
|
await this._initializeChannel(options.hass, options.deviceRegistryManager);
|
||||||
const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig());
|
this._ptzEntities = await this._getPTZEntities(
|
||||||
this._ptzEntities = await this._getPTZEntities(hass, entityRegistry);
|
options.hass,
|
||||||
const reolinkPTZCapabilities = this._ptzEntities
|
options.entityRegistryManager,
|
||||||
? this._entitiesToCapabilities(hass, this._ptzEntities)
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
: null;
|
||||||
|
|
||||||
const combinedPTZCapabilities: PTZCapabilities | null =
|
const combinedPTZ: PTZCapabilities | null =
|
||||||
configPTZCapabilities || reolinkPTZCapabilities
|
configPTZ || reolinkPTZ ? { ...reolinkPTZ, ...configPTZ } : null;
|
||||||
? {
|
|
||||||
...reolinkPTZCapabilities,
|
|
||||||
...configPTZCapabilities,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
this._capabilities = new Capabilities(
|
return {
|
||||||
{
|
...(await super._getRawCapabilities(options)),
|
||||||
'favorite-events': false,
|
clips: true,
|
||||||
'favorite-recordings': false,
|
...(combinedPTZ && { ptz: combinedPTZ }),
|
||||||
'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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _entitiesToCapabilities(
|
protected _entitiesToCapabilities(
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { DeviceRegistryManager } from '../../ha/registry/device';
|
|||||||
import { EntityRegistryManager } from '../../ha/registry/entity/types';
|
import { EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||||
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
import { ResolvedMediaCache } from '../../ha/resolved-media';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { Endpoint } from '../../types';
|
|
||||||
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||||
import { ViewMedia } from '../../view/item';
|
import { ViewMedia } from '../../view/item';
|
||||||
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
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 { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
||||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||||
import {
|
import {
|
||||||
CameraEndpoints,
|
|
||||||
CameraEndpointsContext,
|
|
||||||
CameraEventCallback,
|
CameraEventCallback,
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
CameraManagerRequestCache,
|
CameraManagerRequestCache,
|
||||||
@@ -420,22 +417,4 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
|||||||
engineIcon: 'reolink',
|
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 { ActionsExecutor } from '../../card-controller/actions/types';
|
||||||
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
|
||||||
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
|
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
|
||||||
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { PTZCapabilities, PTZMovementType } from '../../types';
|
import { CapabilitiesRaw, PTZCapabilities, PTZMovementType } from '../../types';
|
||||||
import { Camera } from '../camera';
|
|
||||||
import { Capabilities } from '../capabilities';
|
|
||||||
import { EntityCamera, EntityCameraInitializationOptions } from '../entity-camera';
|
import { EntityCamera, EntityCameraInitializationOptions } from '../entity-camera';
|
||||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||||
|
|
||||||
@@ -22,57 +19,30 @@ type PTZEntity = keyof PTZEntities;
|
|||||||
export class TPLinkCamera extends EntityCamera {
|
export class TPLinkCamera extends EntityCamera {
|
||||||
protected _ptzEntities: PTZEntities | null = null;
|
protected _ptzEntities: PTZEntities | null = null;
|
||||||
|
|
||||||
public async initialize(options: TPLinkCameraInitializationOptions): Promise<Camera> {
|
protected async _initialize(
|
||||||
await super.initialize(options);
|
options: TPLinkCameraInitializationOptions,
|
||||||
await this._initializeCapabilities(
|
): Promise<void> {
|
||||||
|
this._ptzEntities = await this._getPTZEntities(
|
||||||
options.hass,
|
options.hass,
|
||||||
options.entityRegistryManager,
|
options.entityRegistryManager,
|
||||||
options.stateWatcher,
|
|
||||||
);
|
);
|
||||||
return this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _initializeCapabilities(
|
protected async _getRawCapabilities(
|
||||||
hass: HomeAssistant,
|
options: TPLinkCameraInitializationOptions,
|
||||||
entityRegistry: EntityRegistryManager,
|
): Promise<CapabilitiesRaw> {
|
||||||
stateWatcher: StateWatcherSubscriptionInterface,
|
const configPTZ = getPTZCapabilitiesFromCameraConfig(this.getConfig());
|
||||||
): Promise<void> {
|
const tplinkPTZ = this._ptzEntities
|
||||||
const config = this.getConfig();
|
|
||||||
const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(config);
|
|
||||||
this._ptzEntities = await this._getPTZEntities(hass, entityRegistry);
|
|
||||||
const tplinkPTZCapabilities = this._ptzEntities
|
|
||||||
? this._entitiesToCapabilities(this._ptzEntities)
|
? this._entitiesToCapabilities(this._ptzEntities)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const combinedPTZCapabilities: PTZCapabilities | null =
|
const combinedPTZ: PTZCapabilities | null =
|
||||||
configPTZCapabilities || tplinkPTZCapabilities
|
configPTZ || tplinkPTZ ? { ...tplinkPTZ, ...configPTZ } : null;
|
||||||
? {
|
|
||||||
...tplinkPTZCapabilities,
|
|
||||||
...configPTZCapabilities,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
this._capabilities = new Capabilities(
|
return {
|
||||||
{
|
...(await super._getRawCapabilities(options)),
|
||||||
'favorite-events': false,
|
...(combinedPTZ && { ptz: combinedPTZ }),
|
||||||
'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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _getPTZEntities(
|
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>;
|
||||||
@@ -83,8 +83,9 @@ export class MenuButtonController {
|
|||||||
this._getCameraUIButton(config, options?.showCameraUIButton),
|
this._getCameraUIButton(config, options?.showCameraUIButton),
|
||||||
this._getMicrophoneButton(
|
this._getMicrophoneButton(
|
||||||
config,
|
config,
|
||||||
|
cameraManager,
|
||||||
|
options?.view,
|
||||||
options?.microphoneManager,
|
options?.microphoneManager,
|
||||||
options?.currentMediaLoadedInfo,
|
|
||||||
),
|
),
|
||||||
this._getExpandButton(config, options?.inExpandedMode),
|
this._getExpandButton(config, options?.inExpandedMode),
|
||||||
this._getFullscreenButton(config, options?.fullscreenManager),
|
this._getFullscreenButton(config, options?.fullscreenManager),
|
||||||
@@ -381,10 +382,17 @@ export class MenuButtonController {
|
|||||||
|
|
||||||
protected _getMicrophoneButton(
|
protected _getMicrophoneButton(
|
||||||
config: AdvancedCameraCardConfig,
|
config: AdvancedCameraCardConfig,
|
||||||
|
cameraManager: CameraManager,
|
||||||
|
view?: View | null,
|
||||||
microphoneManager?: MicrophoneManager | null,
|
microphoneManager?: MicrophoneManager | null,
|
||||||
currentMediaLoadedInfo?: MediaLoadedInfo | null,
|
|
||||||
): MenuItem | null {
|
): MenuItem | null {
|
||||||
if (microphoneManager && currentMediaLoadedInfo?.capabilities?.supports2WayAudio) {
|
if (!view) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capabilities = cameraManager.getCameraCapabilities(getStreamCameraID(view));
|
||||||
|
|
||||||
|
if (microphoneManager && capabilities?.has('2-way-audio')) {
|
||||||
const unavailable =
|
const unavailable =
|
||||||
microphoneManager.isForbidden() || !microphoneManager.isSupported();
|
microphoneManager.isForbidden() || !microphoneManager.isSupported();
|
||||||
const muted = microphoneManager.isMuted();
|
const muted = microphoneManager.isMuted();
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ import { MicrophoneState } from '../../card-controller/types.js';
|
|||||||
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
||||||
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
|
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
|
||||||
import { PartialZoomSettings } from '../../components-lib/zoom/types.js';
|
import { PartialZoomSettings } from '../../components-lib/zoom/types.js';
|
||||||
import { LiveProvider } from '../../config/schema/cameras.js';
|
|
||||||
import { LiveConfig } from '../../config/schema/live.js';
|
import { LiveConfig } from '../../config/schema/live.js';
|
||||||
import { CardWideConfig, configDefaults } from '../../config/schema/types.js';
|
import { CardWideConfig } from '../../config/schema/types.js';
|
||||||
import { STREAM_TROUBLESHOOTING_URL } from '../../const.js';
|
import { STREAM_TROUBLESHOOTING_URL } from '../../const.js';
|
||||||
import { HomeAssistant } from '../../ha/types.js';
|
import { HomeAssistant } from '../../ha/types.js';
|
||||||
import { localize } from '../../localize/localize.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
@@ -29,6 +28,7 @@ import {
|
|||||||
MediaPlayerController,
|
MediaPlayerController,
|
||||||
MediaPlayerElement,
|
MediaPlayerElement,
|
||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
|
import { getResolvedLiveProvider } from '../../utils/live-provider.js';
|
||||||
import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
|
import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
|
||||||
import '../icon.js';
|
import '../icon.js';
|
||||||
import { renderMessage } from '../message.js';
|
import { renderMessage } from '../message.js';
|
||||||
@@ -101,25 +101,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
return (await this._refProvider.value?.getMediaPlayerController()) ?? null;
|
return (await this._refProvider.value?.getMediaPlayerController()) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the fully resolved live provider.
|
|
||||||
* @returns A live provider (that is not 'auto').
|
|
||||||
*/
|
|
||||||
protected _getResolvedProvider(): Omit<LiveProvider, 'auto'> {
|
|
||||||
const config = this.camera?.getConfig();
|
|
||||||
if (config?.live_provider === 'auto') {
|
|
||||||
if (config?.webrtc_card?.entity || config?.webrtc_card?.url) {
|
|
||||||
return 'webrtc-card';
|
|
||||||
} else if (config?.camera_entity) {
|
|
||||||
return 'ha';
|
|
||||||
} else if (config?.frigate.camera_name) {
|
|
||||||
return 'jsmpeg';
|
|
||||||
}
|
|
||||||
return configDefaults.cameras.live_provider;
|
|
||||||
}
|
|
||||||
return config?.live_provider || 'image';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if a camera image should be shown in lieu of the real stream
|
* Determine if a camera image should be shown in lieu of the real stream
|
||||||
* whilst loading.
|
* whilst loading.
|
||||||
@@ -172,7 +153,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changedProps.has('camera')) {
|
if (changedProps.has('camera')) {
|
||||||
const provider = this._getResolvedProvider();
|
const provider = getResolvedLiveProvider(this.camera?.getConfig());
|
||||||
if (provider === 'jsmpeg') {
|
if (provider === 'jsmpeg') {
|
||||||
this._importPromises.push(import('./providers/jsmpeg.js'));
|
this._importPromises.push(import('./providers/jsmpeg.js'));
|
||||||
} else if (provider === 'ha') {
|
} else if (provider === 'ha') {
|
||||||
@@ -248,7 +229,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
this.title = this.label;
|
this.title = this.label;
|
||||||
this.ariaLabel = this.label;
|
this.ariaLabel = this.label;
|
||||||
|
|
||||||
const provider = this._getResolvedProvider();
|
const provider = getResolvedLiveProvider(this.camera?.getConfig());
|
||||||
|
|
||||||
if (
|
if (
|
||||||
provider === 'ha' ||
|
provider === 'ha' ||
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ import { VideoMediaPlayerController } from '../../../../components-lib/media-pla
|
|||||||
import { MicrophoneConfig } from '../../../../config/schema/live.js';
|
import { MicrophoneConfig } from '../../../../config/schema/live.js';
|
||||||
import { homeAssistantSignPath } from '../../../../ha/sign-path.js';
|
import { homeAssistantSignPath } from '../../../../ha/sign-path.js';
|
||||||
import { HomeAssistant } from '../../../../ha/types.js';
|
import { HomeAssistant } from '../../../../ha/types.js';
|
||||||
import {
|
import { createProxiedEndpointIfNecessary } from '../../../../ha/web-proxy.js';
|
||||||
addDynamicProxyURL,
|
|
||||||
getWebProxiedURL,
|
|
||||||
shouldUseWebProxy,
|
|
||||||
} from '../../../../ha/web-proxy.js';
|
|
||||||
import { localize } from '../../../../localize/localize.js';
|
import { localize } from '../../../../localize/localize.js';
|
||||||
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
|
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
|
||||||
import { MediaPlayer, MediaPlayerController, Message } from '../../../../types.js';
|
import { MediaPlayer, MediaPlayerController, Message } from '../../../../types.js';
|
||||||
@@ -100,12 +96,13 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
|||||||
|
|
||||||
protected async _getPlayerSource(): Promise<string | null> {
|
protected async _getPlayerSource(): Promise<string | null> {
|
||||||
const cameraConfig = this.camera?.getConfig();
|
const cameraConfig = this.camera?.getConfig();
|
||||||
|
const proxyConfig = this.camera?.getProxyConfig();
|
||||||
if (!this.hass || !cameraConfig) {
|
if (!this.hass || !cameraConfig) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const endpoint = this.cameraEndpoints?.go2rtc;
|
const streamEndpoint = this.cameraEndpoints?.go2rtc;
|
||||||
if (!endpoint) {
|
if (!streamEndpoint) {
|
||||||
this._handleError({
|
this._handleError({
|
||||||
message: localize('error.live_camera_no_endpoint'),
|
message: localize('error.live_camera_no_endpoint'),
|
||||||
context: cameraConfig,
|
context: cameraConfig,
|
||||||
@@ -113,56 +110,49 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxyConfig = this.camera?.getProxyConfig();
|
let result: string | null = null;
|
||||||
let src: string | null = endpoint.endpoint;
|
|
||||||
let sign: boolean = endpoint.sign ?? false;
|
|
||||||
|
|
||||||
if (proxyConfig && shouldUseWebProxy(this.hass, proxyConfig, 'live')) {
|
try {
|
||||||
if (proxyConfig.dynamic) {
|
const endpoint = await createProxiedEndpointIfNecessary(
|
||||||
try {
|
this.hass,
|
||||||
await addDynamicProxyURL(this.hass, endpoint.endpoint, {
|
streamEndpoint,
|
||||||
proxyConfig,
|
proxyConfig,
|
||||||
ttl: GO2RTC_URL_SIGN_EXPIRY_SECONDS,
|
{
|
||||||
|
context: 'live',
|
||||||
|
ttl: GO2RTC_URL_SIGN_EXPIRY_SECONDS,
|
||||||
|
websocket: true,
|
||||||
|
|
||||||
// The link may need to be opened multiple times.
|
// The link may need to be opened multiple times.
|
||||||
openLimit: 0,
|
openLimit: 0,
|
||||||
});
|
},
|
||||||
} catch (e) {
|
);
|
||||||
this._handleError(
|
|
||||||
{
|
|
||||||
message: localize('error.failed_proxy'),
|
|
||||||
context: cameraConfig,
|
|
||||||
},
|
|
||||||
e as Error,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
src = getWebProxiedURL(endpoint.endpoint, { websocket: true });
|
if (endpoint.sign) {
|
||||||
sign = true;
|
result = await homeAssistantSignPath(
|
||||||
}
|
|
||||||
|
|
||||||
if (src && sign) {
|
|
||||||
try {
|
|
||||||
src = await homeAssistantSignPath(
|
|
||||||
this.hass,
|
this.hass,
|
||||||
src,
|
endpoint.endpoint,
|
||||||
GO2RTC_URL_SIGN_EXPIRY_SECONDS,
|
GO2RTC_URL_SIGN_EXPIRY_SECONDS,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
if (!result) {
|
||||||
this._handleError(
|
this._handleError({
|
||||||
{
|
|
||||||
message: localize('error.failed_sign'),
|
message: localize('error.failed_sign'),
|
||||||
context: cameraConfig,
|
context: cameraConfig,
|
||||||
},
|
});
|
||||||
e as Error,
|
}
|
||||||
);
|
} else {
|
||||||
return null;
|
result = endpoint.endpoint;
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this._handleError(
|
||||||
|
{
|
||||||
|
message: localize('error.failed_proxy'),
|
||||||
|
context: cameraConfig,
|
||||||
|
},
|
||||||
|
e as Error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return src;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _createPlayer(): Promise<void> {
|
protected async _createPlayer(): Promise<void> {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { mayHaveAudio } from '../../../../utils/audio';
|
import {
|
||||||
|
addAudioTracksMuteStateListener,
|
||||||
|
has2WayAudio,
|
||||||
|
hasAudio,
|
||||||
|
} from '../../../../utils/audio';
|
||||||
import {
|
import {
|
||||||
hideMediaControlsTemporarily,
|
hideMediaControlsTemporarily,
|
||||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
@@ -172,6 +176,29 @@ export class VideoRTC extends HTMLElement {
|
|||||||
* @type {boolean}}
|
* @type {boolean}}
|
||||||
*/
|
*/
|
||||||
this.controls = true;
|
this.controls = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [internal] Cleanup function for audio track mute state listener.
|
||||||
|
* @type {Function | null}
|
||||||
|
*/
|
||||||
|
this._audioTracksMuteStateCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a media loaded event with current capabilities.
|
||||||
|
*/
|
||||||
|
_dispatchMediaLoadedEvent() {
|
||||||
|
dispatchMediaLoadedEvent(this, this.video, {
|
||||||
|
...(this.mediaPlayerController && {
|
||||||
|
mediaPlayerController: this.mediaPlayerController,
|
||||||
|
}),
|
||||||
|
capabilities: {
|
||||||
|
has2WayAudio: has2WayAudio(this.pc),
|
||||||
|
hasAudio: hasAudio(this.video, this.pc, this.mseCodecs),
|
||||||
|
supportsPause: true,
|
||||||
|
},
|
||||||
|
technology: getTechnologyForVideoRTC(this),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -352,21 +379,13 @@ export class VideoRTC extends HTMLElement {
|
|||||||
if (this.controls) {
|
if (this.controls) {
|
||||||
hideMediaControlsTemporarily(this.video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
|
hideMediaControlsTemporarily(this.video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
|
||||||
}
|
}
|
||||||
dispatchMediaLoadedEvent(this, this.video, {
|
this._dispatchMediaLoadedEvent();
|
||||||
...(this.mediaPlayerController && {
|
|
||||||
mediaPlayerController: this.mediaPlayerController,
|
// Listen for audio track mute/unmute changes and re-dispatch
|
||||||
}),
|
this._audioTracksMuteStateCleanup?.();
|
||||||
capabilities: {
|
this._audioTracksMuteStateCleanup = addAudioTracksMuteStateListener(this.pc, () =>
|
||||||
// 2-way audio is only supported on WebRTC connections. The state of
|
this._dispatchMediaLoadedEvent(),
|
||||||
// `this.microphoneStream` is not taken into account here since
|
);
|
||||||
// that can be created after the fact -- this is purely saying that
|
|
||||||
// were a microphone stream available it could be used usefully.
|
|
||||||
supports2WayAudio: !!this.pc,
|
|
||||||
supportsPause: true,
|
|
||||||
hasAudio: mayHaveAudio(this.video),
|
|
||||||
},
|
|
||||||
technology: getTechnologyForVideoRTC(this),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
this.video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this);
|
this.video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this);
|
||||||
this.video.onplay = () => dispatchMediaPlayEvent(this);
|
this.video.onplay = () => dispatchMediaPlayEvent(this);
|
||||||
@@ -415,6 +434,9 @@ export class VideoRTC extends HTMLElement {
|
|||||||
|
|
||||||
this.video.src = '';
|
this.video.src = '';
|
||||||
this.video.srcObject = null;
|
this.video.srcObject = null;
|
||||||
|
|
||||||
|
this._audioTracksMuteStateCleanup?.();
|
||||||
|
this._audioTracksMuteStateCleanup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,11 +22,7 @@ import { isHARelativeURL } from '../../ha/is-ha-relative-url.js';
|
|||||||
import { ResolvedMediaCache, resolveMedia } from '../../ha/resolved-media.js';
|
import { ResolvedMediaCache, resolveMedia } from '../../ha/resolved-media.js';
|
||||||
import { homeAssistantSignPath } from '../../ha/sign-path.js';
|
import { homeAssistantSignPath } from '../../ha/sign-path.js';
|
||||||
import { HomeAssistant, ResolvedMedia } from '../../ha/types.js';
|
import { HomeAssistant, ResolvedMedia } from '../../ha/types.js';
|
||||||
import {
|
import { createProxiedEndpointIfNecessary } from '../../ha/web-proxy.js';
|
||||||
addDynamicProxyURL,
|
|
||||||
getWebProxiedURL,
|
|
||||||
shouldUseWebProxy,
|
|
||||||
} from '../../ha/web-proxy.js';
|
|
||||||
import '../../patches/ha-hls-player.js';
|
import '../../patches/ha-hls-player.js';
|
||||||
import viewerProviderStyle from '../../scss/viewer-provider.scss';
|
import viewerProviderStyle from '../../scss/viewer-provider.scss';
|
||||||
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js';
|
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js';
|
||||||
@@ -153,34 +149,32 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null;
|
const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null;
|
||||||
const proxyConfig = camera?.getProxyConfig();
|
const proxyConfig = camera?.getProxyConfig();
|
||||||
|
|
||||||
if (proxyConfig && shouldUseWebProxy(this.hass, proxyConfig, 'media')) {
|
if (!proxyConfig) {
|
||||||
if (proxyConfig.dynamic) {
|
|
||||||
// Don't use URL() parsing, since that will strip the port number if
|
|
||||||
// it's the default, just need to strip any hash part of the URL.
|
|
||||||
const urlWithoutQSorHash = unsignedURL.split(/#/)[0];
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addDynamicProxyURL(this.hass, urlWithoutQSorHash, {
|
|
||||||
proxyConfig,
|
|
||||||
|
|
||||||
// The link may need to be opened multiple times.
|
|
||||||
openLimit: 0,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
errorToConsole(e as Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this._url = await homeAssistantSignPath(
|
|
||||||
this.hass,
|
|
||||||
getWebProxiedURL(unsignedURL),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
errorToConsole(e as Error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this._url = unsignedURL;
|
this._url = unsignedURL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create endpoint from unsigned URL - it doesn't need signing initially
|
||||||
|
const unsignedEndpoint = { endpoint: unsignedURL, sign: false };
|
||||||
|
const proxiedEndpoint = await createProxiedEndpointIfNecessary(
|
||||||
|
this.hass,
|
||||||
|
unsignedEndpoint,
|
||||||
|
proxyConfig,
|
||||||
|
{
|
||||||
|
context: 'media',
|
||||||
|
// The link may need to be opened multiple times.
|
||||||
|
openLimit: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (proxiedEndpoint.sign) {
|
||||||
|
this._url = await homeAssistantSignPath(this.hass, proxiedEndpoint.endpoint);
|
||||||
|
} else {
|
||||||
|
this._url = proxiedEndpoint.endpoint;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorToConsole(e as Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { ZodSchema } from 'zod';
|
||||||
|
import { localize } from '../localize/localize';
|
||||||
|
import { AdvancedCameraCardError, Endpoint } from '../types';
|
||||||
|
import { homeAssistantSignPath } from './sign-path';
|
||||||
|
import { HomeAssistant } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a JSON response from a signed or unsigned endpoint and validate it
|
||||||
|
* against a Zod schema.
|
||||||
|
* May throw.
|
||||||
|
*
|
||||||
|
* @param hass Home Assistant instance.
|
||||||
|
* @param endpoint The endpoint to fetch from (string or Endpoint object).
|
||||||
|
* @param schema The Zod schema to validate the response against.
|
||||||
|
* @returns The parsed data or throws if fetch/validation fails.
|
||||||
|
*/
|
||||||
|
export const homeAssistantSignAndFetch = async <T>(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
endpoint: Endpoint,
|
||||||
|
schema: ZodSchema<T>,
|
||||||
|
options?: {
|
||||||
|
timeoutSeconds?: number;
|
||||||
|
},
|
||||||
|
): Promise<T> => {
|
||||||
|
let url: string | null = endpoint.endpoint;
|
||||||
|
const sign = endpoint.sign;
|
||||||
|
|
||||||
|
// Sign the path if needed
|
||||||
|
if (sign) {
|
||||||
|
try {
|
||||||
|
url = await homeAssistantSignPath(hass, url);
|
||||||
|
} catch (error) {
|
||||||
|
throw new AdvancedCameraCardError(localize('error.failed_sign'), {
|
||||||
|
endpoint,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
throw new AdvancedCameraCardError(localize('error.failed_sign'), {
|
||||||
|
endpoint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
...(options?.timeoutSeconds && {
|
||||||
|
signal: AbortSignal.timeout(options.timeoutSeconds * 1000),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new AdvancedCameraCardError(`${localize('error.failed_fetch')}: ${url}`, {
|
||||||
|
endpoint,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new AdvancedCameraCardError(localize('error.failed_response'), {
|
||||||
|
endpoint,
|
||||||
|
response,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
throw new AdvancedCameraCardError(localize('error.invalid_response'), {
|
||||||
|
endpoint,
|
||||||
|
response,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(data);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AdvancedCameraCardError(localize('error.invalid_response'), {
|
||||||
|
endpoint,
|
||||||
|
data,
|
||||||
|
error: parsed.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data;
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { LRUCache } from '../cache/lru';
|
import { LRUCache } from '../cache/lru';
|
||||||
import { errorToConsole } from '../utils/basic';
|
import { errorToConsole } from '../utils/basic';
|
||||||
import { HomeAssistant, ResolvedMedia, resolvedMediaSchema } from './types';
|
|
||||||
import { homeAssistantWSRequest } from './ws-request';
|
import { homeAssistantWSRequest } from './ws-request';
|
||||||
|
import { HomeAssistant, ResolvedMedia, resolvedMediaSchema } from './types';
|
||||||
|
|
||||||
// It's important the cache size be at least as large as the largest likely
|
// It's important the cache size be at least as large as the largest likely
|
||||||
// media query or media items will from a given query will be evicted for other
|
// media query or media items will from a given query will be evicted for other
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
import { SignedPath, signedPathSchema } from '../types';
|
import { SignedPath, signedPathSchema } from '../types';
|
||||||
import { HomeAssistant } from './types';
|
|
||||||
import { homeAssistantWSRequest } from './ws-request';
|
import { homeAssistantWSRequest } from './ws-request';
|
||||||
|
import { HomeAssistant } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request that HA sign a path. May throw.
|
* Request that HA sign a path. May throw.
|
||||||
@@ -10,7 +10,6 @@ import { homeAssistantWSRequest } from './ws-request';
|
|||||||
* HA will sign for 30 seconds).
|
* HA will sign for 30 seconds).
|
||||||
* @returns The signed URL, or null if the response was malformed.
|
* @returns The signed URL, or null if the response was malformed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export async function homeAssistantSignPath(
|
export async function homeAssistantSignPath(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
path: string,
|
path: string,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CameraProxyConfig } from '../camera-manager/types';
|
import { CameraProxyConfig } from '../camera-manager/types';
|
||||||
|
import { Endpoint } from '../types';
|
||||||
import { HomeAssistant } from './types';
|
import { HomeAssistant } from './types';
|
||||||
|
|
||||||
export const HASS_WEB_PROXY_DOMAIN = 'hass_web_proxy';
|
export const HASS_WEB_PROXY_DOMAIN = 'hass_web_proxy';
|
||||||
@@ -59,3 +60,45 @@ export async function addDynamicProxyURL(
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CreateProxiedEndpointOptions {
|
||||||
|
context?: 'live' | 'media';
|
||||||
|
ttl?: number;
|
||||||
|
websocket?: boolean;
|
||||||
|
openLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a proxied endpoint if the proxy configuration requires it.
|
||||||
|
* Handles dynamic proxy registration and returns a proxied Endpoint.
|
||||||
|
* @param hass Home Assistant instance.
|
||||||
|
* @param endpoint The endpoint to potentially proxy.
|
||||||
|
* @param proxyConfig The camera proxy configuration. If undefined, returns original endpoint.
|
||||||
|
* @param options Additional options for proxy registration.
|
||||||
|
* @returns Proxied Endpoint if proxying needed, original endpoint otherwise.
|
||||||
|
*/
|
||||||
|
export const createProxiedEndpointIfNecessary = async (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
endpoint: Endpoint,
|
||||||
|
proxyConfig?: CameraProxyConfig,
|
||||||
|
options?: CreateProxiedEndpointOptions,
|
||||||
|
): Promise<Endpoint> => {
|
||||||
|
const context = options?.context ?? 'media';
|
||||||
|
if (!proxyConfig || !shouldUseWebProxy(hass, proxyConfig, context)) {
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
if (proxyConfig.dynamic) {
|
||||||
|
// Strip hash fragment for registration - it's client-side only and
|
||||||
|
// not relevant for proxy pattern matching.
|
||||||
|
const registrationUrl = endpoint.endpoint.split(/#/)[0];
|
||||||
|
await addDynamicProxyURL(hass, registrationUrl, {
|
||||||
|
proxyConfig,
|
||||||
|
ttl: options?.ttl,
|
||||||
|
openLimit: options?.openLimit ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
endpoint: getWebProxiedURL(endpoint.endpoint, { websocket: options?.websocket }),
|
||||||
|
sign: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { HomeAssistant } from './types';
|
|||||||
* @param request The request to make.
|
* @param request The request to make.
|
||||||
* @returns The parsed valid response or null on malformed.
|
* @returns The parsed valid response or null on malformed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export async function homeAssistantWSRequest<T>(
|
export async function homeAssistantWSRequest<T>(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
schema: ZodSchema<T>,
|
schema: ZodSchema<T>,
|
||||||
|
|||||||
@@ -682,6 +682,7 @@
|
|||||||
"duplicate_camera_id": "Duplicate camera id for the following camera, use the 'id' parameter to uniquely identify cameras",
|
"duplicate_camera_id": "Duplicate camera id for the following camera, use the 'id' parameter to uniquely identify cameras",
|
||||||
"duplicate_folder_id": "Duplicate folder id for the following folder, use the 'id' parameter to uniquely identify folders",
|
"duplicate_folder_id": "Duplicate folder id for the following folder, use the 'id' parameter to uniquely identify folders",
|
||||||
"empty_response": "Received empty response from Home Assistant for request",
|
"empty_response": "Received empty response from Home Assistant for request",
|
||||||
|
"failed_fetch": "Could not fetch URL",
|
||||||
"failed_proxy": "Could not proxy via Home Assistant",
|
"failed_proxy": "Could not proxy via Home Assistant",
|
||||||
"failed_response": "Failed to receive response from Home Assistant for request",
|
"failed_response": "Failed to receive response from Home Assistant for request",
|
||||||
"failed_retain": "Could not retain event",
|
"failed_retain": "Could not retain event",
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import { VideoMediaPlayerController } from '../components-lib/media-player/video
|
|||||||
import { renderMessage } from '../components/message.js';
|
import { renderMessage } from '../components/message.js';
|
||||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
||||||
import { MediaPlayer, MediaPlayerController } from '../types.js';
|
import { MediaPlayer, MediaPlayerController } from '../types.js';
|
||||||
import { mayHaveAudio } from '../utils/audio.js';
|
import {
|
||||||
|
addAudioTracksMuteStateListener,
|
||||||
|
AudioTracksMuteStateCleanup,
|
||||||
|
hasAudio,
|
||||||
|
} from '../utils/audio.js';
|
||||||
import {
|
import {
|
||||||
hideMediaControlsTemporarily,
|
hideMediaControlsTemporarily,
|
||||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
@@ -44,6 +48,8 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
|
|||||||
() => this.controls,
|
() => this.controls,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
protected _audioTracksMuteStateCleanup: AudioTracksMuteStateCleanup = null;
|
||||||
|
|
||||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||||
return this._mediaPlayerController;
|
return this._mediaPlayerController;
|
||||||
}
|
}
|
||||||
@@ -132,10 +138,32 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
|
|||||||
mediaPlayerController: this._mediaPlayerController,
|
mediaPlayerController: this._mediaPlayerController,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
supportsPause: true,
|
supportsPause: true,
|
||||||
hasAudio: mayHaveAudio(this._videoEl),
|
hasAudio: hasAudio(this._videoEl, this._peerConnection),
|
||||||
},
|
},
|
||||||
technology: ['webrtc'],
|
technology: ['webrtc'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for audio track mute/unmute changes and re-dispatch
|
||||||
|
this._audioTracksMuteStateCleanup?.();
|
||||||
|
this._audioTracksMuteStateCleanup = addAudioTracksMuteStateListener(
|
||||||
|
this._peerConnection,
|
||||||
|
() => {
|
||||||
|
dispatchMediaLoadedEvent(this, this._videoEl, {
|
||||||
|
mediaPlayerController: this._mediaPlayerController,
|
||||||
|
capabilities: {
|
||||||
|
supportsPause: true,
|
||||||
|
hasAudio: hasAudio(this._videoEl, this._peerConnection),
|
||||||
|
},
|
||||||
|
technology: ['webrtc'],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cleanUp(): void {
|
||||||
|
super._cleanUp();
|
||||||
|
this._audioTracksMuteStateCleanup?.();
|
||||||
|
this._audioTracksMuteStateCleanup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
|
|||||||
+9
-1
@@ -15,9 +15,14 @@ export class AdvancedCameraCardError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaLoadedCapabilities {
|
export interface MediaLoadedCapabilities {
|
||||||
supports2WayAudio?: boolean;
|
|
||||||
supportsPause?: boolean;
|
supportsPause?: boolean;
|
||||||
|
|
||||||
hasAudio?: boolean;
|
hasAudio?: boolean;
|
||||||
|
|
||||||
|
// Note: This is whether the current stream already _has_ 2-way audio, not
|
||||||
|
// whether the underlying camera _could_ establish 2 way audio. For the
|
||||||
|
// latter, consult the camera's capabilities ('2-way-audio').
|
||||||
|
has2WayAudio?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MediaTechnology =
|
export type MediaTechnology =
|
||||||
@@ -132,6 +137,8 @@ export interface CapabilitiesRaw {
|
|||||||
menu?: boolean;
|
menu?: boolean;
|
||||||
|
|
||||||
trigger?: boolean;
|
trigger?: boolean;
|
||||||
|
|
||||||
|
'2-way-audio'?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CapabilityKey = keyof CapabilitiesRaw;
|
export type CapabilityKey = keyof CapabilitiesRaw;
|
||||||
@@ -147,6 +154,7 @@ export const capabilityKeys: readonly [CapabilityKey, ...CapabilityKey[]] = [
|
|||||||
'seek',
|
'seek',
|
||||||
'snapshots',
|
'snapshots',
|
||||||
'substream',
|
'substream',
|
||||||
|
'2-way-audio',
|
||||||
'trigger',
|
'trigger',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -22,3 +22,110 @@ export const mayHaveAudio = (video: HTMLVideoElement & AudioProperties): boolean
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if audio is available for a go2rtc stream.
|
||||||
|
* @param pc The RTCPeerConnection (for WebRTC streams).
|
||||||
|
* @param mseCodecs The negotiated MSE codecs string (for MSE streams).
|
||||||
|
* @param video The video element (fallback for browser-based detection).
|
||||||
|
* @returns True if audio is available.
|
||||||
|
*/
|
||||||
|
export const hasAudio = (
|
||||||
|
video: HTMLVideoElement & AudioProperties,
|
||||||
|
pc?: RTCPeerConnection | null,
|
||||||
|
mseCodecs?: string,
|
||||||
|
): boolean => {
|
||||||
|
// For WebRTC: Check if there's an audio receiver with an active track.
|
||||||
|
// We check that the track is not muted because muted means no media data
|
||||||
|
// is flowing (e.g., the source isn't producing audio). It is not related to
|
||||||
|
// the audio being muted by the user on the receiving end.
|
||||||
|
if (pc) {
|
||||||
|
const receivers = pc.getReceivers();
|
||||||
|
|
||||||
|
// Only trust receivers if they're populated (connection established)
|
||||||
|
if (receivers.length > 0) {
|
||||||
|
return receivers.some(
|
||||||
|
(receiver) => receiver.track?.kind === 'audio' && !receiver.track?.muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For MSE: Check negotiated codecs for audio codecs
|
||||||
|
if (mseCodecs) {
|
||||||
|
return (
|
||||||
|
mseCodecs.includes('mp4a') ||
|
||||||
|
mseCodecs.includes('opus') ||
|
||||||
|
mseCodecs.includes('flac')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fallback to browser-based detection (unreliable in Chrome)
|
||||||
|
return mayHaveAudio(video);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a WebRTC peer connection has an outbound audio channel (i.e. 2-way
|
||||||
|
* audio / microphone support).
|
||||||
|
* @param pc The RTCPeerConnection to check.
|
||||||
|
* @returns True if the connection has an audio transceiver configured to send.
|
||||||
|
*/
|
||||||
|
export const has2WayAudio = (pc: RTCPeerConnection | null): boolean => {
|
||||||
|
return !!pc
|
||||||
|
?.getTransceivers()
|
||||||
|
.some(
|
||||||
|
(tr) =>
|
||||||
|
tr.sender.track?.kind === 'audio' &&
|
||||||
|
(tr.direction === 'sendonly' || tr.direction === 'sendrecv'),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AudioTracksMuteStateCleanup = (() => void) | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add listeners for mute/unmute events on all audio tracks in a WebRTC connection.
|
||||||
|
* The callback is fired when the aggregate mute state changes between:
|
||||||
|
* - All tracks unmuted (hasAudio = true)
|
||||||
|
* - All tracks muted (hasAudio = false)
|
||||||
|
* Mixed states (some muted, some unmuted) do not trigger the callback.
|
||||||
|
* @param pc The RTCPeerConnection to monitor.
|
||||||
|
* @param handler Callback fired with `true` when ALL tracks become unmuted,
|
||||||
|
* `false` when ALL tracks become muted.
|
||||||
|
* @returns A cleanup function to remove listeners, or null if no audio tracks.
|
||||||
|
*/
|
||||||
|
export const addAudioTracksMuteStateListener = (
|
||||||
|
pc: RTCPeerConnection | null,
|
||||||
|
handler: (hasAudio: boolean) => void,
|
||||||
|
): AudioTracksMuteStateCleanup => {
|
||||||
|
if (!pc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioTracks = pc
|
||||||
|
.getReceivers()
|
||||||
|
.map((r) => r.track)
|
||||||
|
.filter((t): t is MediaStreamTrack => t?.kind === 'audio');
|
||||||
|
|
||||||
|
if (audioTracks.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAnyUnmuted = () => audioTracks.some((t) => !t.muted);
|
||||||
|
let lastHasAudio = hasAnyUnmuted();
|
||||||
|
|
||||||
|
const _handler = () => {
|
||||||
|
const nowHasAudio = hasAnyUnmuted();
|
||||||
|
if (nowHasAudio !== lastHasAudio) {
|
||||||
|
lastHasAudio = nowHasAudio;
|
||||||
|
handler(nowHasAudio);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
audioTracks.forEach((track) => {
|
||||||
|
track.addEventListener('unmute', _handler);
|
||||||
|
track.addEventListener('mute', _handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
audioTracks.forEach((track) => {
|
||||||
|
track.removeEventListener('unmute', _handler);
|
||||||
|
track.removeEventListener('mute', _handler);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { CameraProxyConfig } from '../camera-manager/types';
|
||||||
|
import { supports2WayAudio as gortcSupports2WayAudio } from '../camera-manager/utils/go2rtc/audio';
|
||||||
|
import { CameraConfig } from '../config/schema/cameras';
|
||||||
|
import { LiveProvider } from '../config/schema/cameras.js';
|
||||||
|
import { HomeAssistant } from '../ha/types';
|
||||||
|
import { Endpoint } from '../types';
|
||||||
|
|
||||||
|
export const getResolvedLiveProvider = (
|
||||||
|
config: CameraConfig | undefined,
|
||||||
|
): Exclude<LiveProvider, 'auto'> => {
|
||||||
|
if (config?.live_provider === 'auto') {
|
||||||
|
if (config.webrtc_card?.entity || config.webrtc_card?.url) {
|
||||||
|
return 'webrtc-card';
|
||||||
|
} else if (config.camera_entity) {
|
||||||
|
return 'ha';
|
||||||
|
} else if (config.frigate?.camera_name) {
|
||||||
|
return 'jsmpeg';
|
||||||
|
}
|
||||||
|
// Default for auto is 'image'
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
return config?.live_provider ?? 'image';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const liveProviderSupports2WayAudio = async (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config: CameraConfig,
|
||||||
|
go2rtcMetadataEndpoint?: Endpoint | null,
|
||||||
|
proxyConfig?: CameraProxyConfig,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (getResolvedLiveProvider(config) !== 'go2rtc') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return gortcSupports2WayAudio(hass, go2rtcMetadataEndpoint, proxyConfig);
|
||||||
|
};
|
||||||
@@ -4,13 +4,18 @@ import { Camera } from '../../src/camera-manager/camera.js';
|
|||||||
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic.js';
|
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic.js';
|
||||||
import { CameraProxyConfig } from '../../src/camera-manager/types.js';
|
import { CameraProxyConfig } from '../../src/camera-manager/types.js';
|
||||||
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
|
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
|
||||||
|
import { liveProviderSupports2WayAudio } from '../../src/utils/live-provider.js';
|
||||||
import {
|
import {
|
||||||
callStateWatcherCallback,
|
callStateWatcherCallback,
|
||||||
createCameraConfig,
|
createCameraConfig,
|
||||||
createCapabilities,
|
createCapabilities,
|
||||||
|
createHASS,
|
||||||
|
createInitializedCamera,
|
||||||
createStateEntity,
|
createStateEntity,
|
||||||
} from '../test-utils.js';
|
} from '../test-utils.js';
|
||||||
|
|
||||||
|
vi.mock('../../src/utils/live-provider.js');
|
||||||
|
|
||||||
describe('Camera', () => {
|
describe('Camera', () => {
|
||||||
it('should get config', async () => {
|
it('should get config', async () => {
|
||||||
const config = createCameraConfig();
|
const config = createCameraConfig();
|
||||||
@@ -24,12 +29,10 @@ describe('Camera', () => {
|
|||||||
describe('should get capabilities', async () => {
|
describe('should get capabilities', async () => {
|
||||||
it('when populated', async () => {
|
it('when populated', async () => {
|
||||||
const capabilities = createCapabilities();
|
const capabilities = createCapabilities();
|
||||||
const camera = new Camera(
|
const camera = await createInitializedCamera(
|
||||||
createCameraConfig(),
|
createCameraConfig(),
|
||||||
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
{
|
capabilities,
|
||||||
capabilities: capabilities,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
expect(camera.getCapabilities()).toBe(capabilities);
|
expect(camera.getCapabilities()).toBe(capabilities);
|
||||||
});
|
});
|
||||||
@@ -72,29 +75,85 @@ describe('Camera', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should initialize and destroy', async () => {
|
describe('initialize', () => {
|
||||||
const camera = new Camera(
|
it('should initialize and destroy', async () => {
|
||||||
createCameraConfig({
|
const camera = new Camera(
|
||||||
triggers: {
|
createCameraConfig({
|
||||||
entities: ['camera.foo'],
|
triggers: {
|
||||||
},
|
entities: ['camera.foo'],
|
||||||
}),
|
},
|
||||||
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
}),
|
||||||
{
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
capabilities: createCapabilities({ trigger: true }),
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
await camera.initialize({
|
await camera.initialize({
|
||||||
stateWatcher: stateWatcher,
|
hass: createHASS(),
|
||||||
|
stateWatcher: stateWatcher,
|
||||||
|
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stateWatcher.subscribe).toBeCalledWith(expect.any(Function), [
|
||||||
|
'camera.foo',
|
||||||
|
]);
|
||||||
|
|
||||||
|
await camera.destroy();
|
||||||
|
|
||||||
|
expect(stateWatcher.unsubscribe).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(stateWatcher.subscribe).toBeCalledWith(expect.any(Function), ['camera.foo']);
|
it('should set capabilities and use go2rtc metadata endpoint', async () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
go2rtc: {
|
||||||
|
url: 'http://go2rtc',
|
||||||
|
stream: 'stream',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
|
||||||
await camera.destroy();
|
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
|
||||||
|
|
||||||
expect(stateWatcher.unsubscribe).toBeCalled();
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
{
|
||||||
|
endpoint:
|
||||||
|
'http://go2rtc/api/streams?src=stream&video=all&audio=allµphone',
|
||||||
|
sign: false,
|
||||||
|
},
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set capabilities when go2rtc metadata endpoint fails', async () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
go2rtc: {
|
||||||
|
url: 'http://go2rtc',
|
||||||
|
stream: 'stream',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(false);
|
||||||
|
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should handle trigger state changes', () => {
|
describe('should handle trigger state changes', () => {
|
||||||
@@ -120,14 +179,15 @@ describe('Camera', () => {
|
|||||||
}),
|
}),
|
||||||
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
{
|
{
|
||||||
capabilities: createCapabilities({ trigger: true }),
|
|
||||||
eventCallback: eventCallback,
|
eventCallback: eventCallback,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
await camera.initialize({
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
stateWatcher: stateWatcher,
|
stateWatcher: stateWatcher,
|
||||||
|
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(stateWatcher.subscribe).toBeCalled();
|
expect(stateWatcher.subscribe).toBeCalled();
|
||||||
@@ -157,14 +217,15 @@ describe('Camera', () => {
|
|||||||
}),
|
}),
|
||||||
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
{
|
{
|
||||||
capabilities: createCapabilities({ trigger: false }),
|
|
||||||
eventCallback: eventCallback,
|
eventCallback: eventCallback,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
await camera.initialize({
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
stateWatcher: stateWatcher,
|
stateWatcher: stateWatcher,
|
||||||
|
capabilityOptions: { capabilities: createCapabilities({ trigger: false }) },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(stateWatcher.subscribe).not.toBeCalled();
|
expect(stateWatcher.subscribe).not.toBeCalled();
|
||||||
@@ -331,4 +392,40 @@ describe('Camera', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getEndpoints', () => {
|
||||||
|
it('should return null when no endpoints are available', () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
go2rtc: { stream: '' },
|
||||||
|
camera_entity: '',
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
expect(camera.getEndpoints()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly merge endpoints', async () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
go2rtc: {
|
||||||
|
url: 'http://go2rtc',
|
||||||
|
stream: 'stream',
|
||||||
|
},
|
||||||
|
camera_entity: 'camera.foo',
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(camera.getEndpoints()).toEqual({
|
||||||
|
go2rtc: {
|
||||||
|
endpoint: 'http://go2rtc/api/ws?src=stream',
|
||||||
|
sign: false,
|
||||||
|
},
|
||||||
|
webrtcCard: {
|
||||||
|
endpoint: 'camera.foo',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,13 +3,21 @@ import { mock } from 'vitest-mock-extended';
|
|||||||
import { CameraManagerEngine } from '../../../src/camera-manager/engine';
|
import { CameraManagerEngine } from '../../../src/camera-manager/engine';
|
||||||
import { FrigateCamera } from '../../../src/camera-manager/frigate/camera';
|
import { FrigateCamera } from '../../../src/camera-manager/frigate/camera';
|
||||||
import { FrigateEventWatcher } from '../../../src/camera-manager/frigate/event-watcher';
|
import { FrigateEventWatcher } from '../../../src/camera-manager/frigate/event-watcher';
|
||||||
|
import {
|
||||||
|
FrigateEventViewMedia,
|
||||||
|
FrigateRecordingViewMedia,
|
||||||
|
} from '../../../src/camera-manager/frigate/media';
|
||||||
import { getPTZInfo } from '../../../src/camera-manager/frigate/requests';
|
import { getPTZInfo } from '../../../src/camera-manager/frigate/requests';
|
||||||
import { FrigateEventChange } from '../../../src/camera-manager/frigate/types';
|
import {
|
||||||
|
eventSchema,
|
||||||
|
FrigateEventChange,
|
||||||
|
} from '../../../src/camera-manager/frigate/types';
|
||||||
import { ActionsExecutor } from '../../../src/card-controller/actions/types';
|
import { ActionsExecutor } from '../../../src/card-controller/actions/types';
|
||||||
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
||||||
import { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
|
import { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
|
||||||
import { CameraTriggerEventType } from '../../../src/config/schema/cameras';
|
import { CameraTriggerEventType } from '../../../src/config/schema/cameras';
|
||||||
import { Entity, EntityRegistryManager } from '../../../src/ha/registry/entity/types';
|
import { Entity, EntityRegistryManager } from '../../../src/ha/registry/entity/types';
|
||||||
|
import { ViewMediaType } from '../../../src/view/item';
|
||||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||||
import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils';
|
import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils';
|
||||||
|
|
||||||
@@ -297,6 +305,294 @@ describe('FrigateCamera', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getEndpoints', () => {
|
||||||
|
describe('getUIEndpoint', () => {
|
||||||
|
it('should return null when no frigate URL is set', () => {
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
frigate: {
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
expect(camera.getEndpoints()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return frigate URL when no camera name is set', () => {
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
frigate: {
|
||||||
|
url: 'http://frigate',
|
||||||
|
camera_name: '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
expect(camera.getEndpoints({ view: 'live' })?.ui).toEqual({
|
||||||
|
endpoint: 'http://frigate',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return camera URL for live view', () => {
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
frigate: {
|
||||||
|
url: 'http://frigate',
|
||||||
|
camera_name: 'front_door',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
expect(camera.getEndpoints({ view: 'live' })?.ui).toEqual({
|
||||||
|
endpoint: 'http://frigate/#front_door',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return events URL for clip media', () => {
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
frigate: {
|
||||||
|
url: 'http://frigate',
|
||||||
|
camera_name: 'front_door',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
const event = eventSchema.parse({
|
||||||
|
camera: 'front_door',
|
||||||
|
id: 'event-id',
|
||||||
|
label: 'person',
|
||||||
|
start_time: 100,
|
||||||
|
end_time: 200,
|
||||||
|
has_clip: true,
|
||||||
|
has_snapshot: true,
|
||||||
|
retain_indefinitely: false,
|
||||||
|
false_positive: false,
|
||||||
|
sub_label: '',
|
||||||
|
top_score: 0.8,
|
||||||
|
zones: [],
|
||||||
|
});
|
||||||
|
const media = new FrigateEventViewMedia(
|
||||||
|
ViewMediaType.Clip,
|
||||||
|
'front_door',
|
||||||
|
event,
|
||||||
|
'content-id',
|
||||||
|
'thumbnail',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(camera.getEndpoints({ view: 'media', media })?.ui).toEqual({
|
||||||
|
endpoint: 'http://frigate/events?camera=front_door',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return events URL for snapshot media', () => {
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
frigate: {
|
||||||
|
url: 'http://frigate',
|
||||||
|
camera_name: 'front_door',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
const event = eventSchema.parse({
|
||||||
|
camera: 'front_door',
|
||||||
|
id: 'event-id',
|
||||||
|
label: 'person',
|
||||||
|
start_time: 100,
|
||||||
|
end_time: 200,
|
||||||
|
has_clip: true,
|
||||||
|
has_snapshot: true,
|
||||||
|
retain_indefinitely: false,
|
||||||
|
false_positive: false,
|
||||||
|
sub_label: '',
|
||||||
|
top_score: 0.8,
|
||||||
|
zones: [],
|
||||||
|
});
|
||||||
|
const media = new FrigateEventViewMedia(
|
||||||
|
ViewMediaType.Snapshot,
|
||||||
|
'front_door',
|
||||||
|
event,
|
||||||
|
'content-id',
|
||||||
|
'thumbnail',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(camera.getEndpoints({ view: 'media', media })?.ui).toEqual({
|
||||||
|
endpoint: 'http://frigate/events?camera=front_door',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return recordings URL with time for recording media with startTime', () => {
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
frigate: {
|
||||||
|
url: 'http://frigate',
|
||||||
|
camera_name: 'front_door',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
const media = new FrigateRecordingViewMedia(
|
||||||
|
ViewMediaType.Recording,
|
||||||
|
'front_door',
|
||||||
|
{
|
||||||
|
cameraID: 'front_door',
|
||||||
|
startTime: new Date('2023-01-01T10:00:00Z'),
|
||||||
|
endTime: new Date('2023-01-01T11:00:00Z'),
|
||||||
|
events: 0,
|
||||||
|
},
|
||||||
|
'recording-id',
|
||||||
|
'content-id',
|
||||||
|
'title',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(camera.getEndpoints({ view: 'media', media })?.ui).toEqual({
|
||||||
|
endpoint: 'http://frigate/recording/front_door/2023-01-01/10',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return recordings URL without time for recording media without startTime', () => {
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
frigate: {
|
||||||
|
url: 'http://frigate',
|
||||||
|
camera_name: 'front_door',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
// Create a media object where getStartTime returns null
|
||||||
|
const media = new FrigateRecordingViewMedia(
|
||||||
|
ViewMediaType.Recording,
|
||||||
|
'front_door',
|
||||||
|
{
|
||||||
|
cameraID: 'front_door',
|
||||||
|
// Forced null for test
|
||||||
|
startTime: null as unknown as Date,
|
||||||
|
endTime: new Date('2023-01-01T11:00:00Z'),
|
||||||
|
events: 0,
|
||||||
|
},
|
||||||
|
'recording-id',
|
||||||
|
'content-id',
|
||||||
|
'title',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(camera.getEndpoints({ view: 'media', media })?.ui).toEqual({
|
||||||
|
endpoint: 'http://frigate/recording/front_door',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return events URL for clip/clips/snapshots/snapshot views', () => {
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
frigate: {
|
||||||
|
url: 'http://frigate',
|
||||||
|
camera_name: 'front_door',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
expect(camera.getEndpoints({ view: 'clip' })?.ui?.endpoint).toBe(
|
||||||
|
'http://frigate/events?camera=front_door',
|
||||||
|
);
|
||||||
|
expect(camera.getEndpoints({ view: 'clips' })?.ui?.endpoint).toBe(
|
||||||
|
'http://frigate/events?camera=front_door',
|
||||||
|
);
|
||||||
|
expect(camera.getEndpoints({ view: 'snapshots' })?.ui?.endpoint).toBe(
|
||||||
|
'http://frigate/events?camera=front_door',
|
||||||
|
);
|
||||||
|
expect(camera.getEndpoints({ view: 'snapshot' })?.ui?.endpoint).toBe(
|
||||||
|
'http://frigate/events?camera=front_door',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return recordings URL for recording/recordings views', () => {
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
frigate: {
|
||||||
|
url: 'http://frigate',
|
||||||
|
camera_name: 'front_door',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
expect(camera.getEndpoints({ view: 'recording' })?.ui?.endpoint).toBe(
|
||||||
|
'http://frigate/recording/front_door',
|
||||||
|
);
|
||||||
|
expect(camera.getEndpoints({ view: 'recordings' })?.ui?.endpoint).toBe(
|
||||||
|
'http://frigate/recording/front_door',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return camera URL as default fallback', () => {
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
frigate: {
|
||||||
|
url: 'http://frigate',
|
||||||
|
camera_name: 'front_door',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
expect(camera.getEndpoints({ view: 'timeline' })?.ui).toEqual({
|
||||||
|
endpoint: 'http://frigate/#front_door',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGo2RTCStreamEndpoint', () => {
|
||||||
|
it('should return default frigate go2rtc paths', () => {
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
frigate: {
|
||||||
|
client_id: 'frigate',
|
||||||
|
camera_name: 'front_door',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
const endpoints = camera.getEndpoints();
|
||||||
|
expect(endpoints?.go2rtc).toEqual({
|
||||||
|
endpoint: '/api/frigate/frigate/mse/api/ws?src=front_door',
|
||||||
|
sign: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getJSMPEGEndpoint', () => {
|
||||||
|
it('should return default frigate jsmpeg path', () => {
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
frigate: {
|
||||||
|
client_id: 'frigate',
|
||||||
|
camera_name: 'front_door',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
const endpoints = camera.getEndpoints();
|
||||||
|
expect(endpoints?.jsmpeg).toEqual({
|
||||||
|
endpoint: '/api/frigate/frigate/jsmpeg/front_door',
|
||||||
|
sign: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null if no camera name is set', () => {
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
frigate: {
|
||||||
|
camera_name: '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
);
|
||||||
|
const endpoints = camera.getEndpoints();
|
||||||
|
expect(endpoints?.jsmpeg).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('should handle events', () => {
|
describe('should handle events', () => {
|
||||||
it('should subscribe', async () => {
|
it('should subscribe', async () => {
|
||||||
const camera = new FrigateCamera(
|
const camera = new FrigateCamera(
|
||||||
@@ -654,6 +950,53 @@ describe('FrigateCamera', () => {
|
|||||||
expect(eventCallback).toHaveBeenCalledTimes(call ? 1 : 0);
|
expect(eventCallback).toHaveBeenCalledTimes(call ? 1 : 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should ignore events when camera ID is not set', async () => {
|
||||||
|
const eventCallback = vi.fn();
|
||||||
|
const camera = new FrigateCamera(
|
||||||
|
createCameraConfig({
|
||||||
|
// Note: No 'id' is set here
|
||||||
|
frigate: {
|
||||||
|
camera_name: 'camera.front_door',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mock<CameraManagerEngine>(),
|
||||||
|
{
|
||||||
|
eventCallback: eventCallback,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const hass = createHASS();
|
||||||
|
const eventWatcher = mock<FrigateEventWatcher>();
|
||||||
|
await camera.initialize({
|
||||||
|
hass: hass,
|
||||||
|
entityRegistryManager: mock<EntityRegistryManager>(),
|
||||||
|
stateWatcher: mock<StateWatcher>(),
|
||||||
|
frigateEventWatcher: eventWatcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
callEventWatcherCallback(eventWatcher, {
|
||||||
|
type: 'new',
|
||||||
|
before: {
|
||||||
|
camera: 'camera.front_door',
|
||||||
|
snapshot: null,
|
||||||
|
has_clip: false,
|
||||||
|
has_snapshot: false,
|
||||||
|
label: 'person',
|
||||||
|
current_zones: [],
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
camera: 'camera.front_door',
|
||||||
|
snapshot: null,
|
||||||
|
has_clip: false,
|
||||||
|
has_snapshot: true,
|
||||||
|
label: 'person',
|
||||||
|
current_zones: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(eventCallback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/t
|
|||||||
import { CameraManagerRequestCache } from '../../../src/camera-manager/types';
|
import { CameraManagerRequestCache } from '../../../src/camera-manager/types';
|
||||||
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
||||||
import { CameraConfig } from '../../../src/config/schema/cameras';
|
import { CameraConfig } from '../../../src/config/schema/cameras';
|
||||||
import { AdvancedCameraCardView } from '../../../src/config/schema/common/const';
|
|
||||||
import { RawAdvancedCameraCardConfig } from '../../../src/config/types';
|
import { RawAdvancedCameraCardConfig } from '../../../src/config/types';
|
||||||
import { ViewMedia, ViewMediaType } from '../../../src/view/item';
|
import { ViewMedia, ViewMediaType } from '../../../src/view/item';
|
||||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||||
import { TestViewMedia, createCameraConfig, createHASS } from '../../test-utils';
|
import { createCameraConfig, createHASS } from '../../test-utils';
|
||||||
|
|
||||||
const createEngine = (): FrigateCameraManagerEngine => {
|
const createEngine = (): FrigateCameraManagerEngine => {
|
||||||
return new FrigateCameraManagerEngine(
|
return new FrigateCameraManagerEngine(
|
||||||
@@ -141,265 +140,3 @@ describe('getMediaDownloadPath', () => {
|
|||||||
expect(endpoint).toBeNull();
|
expect(endpoint).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getCameraEndpoints', () => {
|
|
||||||
it('should get basic endpoints', () => {
|
|
||||||
const endpoints = createEngine().getCameraEndpoints(createFrigateCameraConfig());
|
|
||||||
|
|
||||||
expect(endpoints).toEqual({
|
|
||||||
go2rtc: {
|
|
||||||
endpoint: '/api/frigate/frigate/mse/api/ws?src=camera-1',
|
|
||||||
sign: true,
|
|
||||||
},
|
|
||||||
jsmpeg: {
|
|
||||||
endpoint: '/api/frigate/frigate/jsmpeg/camera-1',
|
|
||||||
sign: true,
|
|
||||||
},
|
|
||||||
webrtcCard: {
|
|
||||||
endpoint: 'camera.office',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('should get overridden go2rtc url', () => {
|
|
||||||
it('when local HA path', () => {
|
|
||||||
const endpoints = createEngine().getCameraEndpoints(
|
|
||||||
createFrigateCameraConfig({
|
|
||||||
go2rtc: {
|
|
||||||
url: '/local/path',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(endpoints).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
go2rtc: {
|
|
||||||
endpoint: '/local/path/api/ws?src=camera-1',
|
|
||||||
sign: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('when remote', () => {
|
|
||||||
const endpoints = createEngine().getCameraEndpoints(
|
|
||||||
createFrigateCameraConfig({
|
|
||||||
go2rtc: {
|
|
||||||
url: 'https://my.custom.go2rtc',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(endpoints).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
go2rtc: {
|
|
||||||
endpoint: 'https://my.custom.go2rtc/api/ws?src=camera-1',
|
|
||||||
sign: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not set webrtc_card endpoint without camera name', () => {
|
|
||||||
const endpoints = createEngine().getCameraEndpoints(createCameraConfig());
|
|
||||||
|
|
||||||
expect(endpoints).not.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
webrtcCard: expect.anything(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('should include UI endpoint', () => {
|
|
||||||
it('with basic url', () => {
|
|
||||||
const endpoints = createEngine().getCameraEndpoints(
|
|
||||||
createCameraConfig({
|
|
||||||
frigate: {
|
|
||||||
url: 'http://my.frigate',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(endpoints).not.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
ui: {
|
|
||||||
url: 'http://my.frigate',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('with camera name', () => {
|
|
||||||
const endpoints = createEngine().getCameraEndpoints(
|
|
||||||
createCameraConfig({
|
|
||||||
frigate: {
|
|
||||||
url: 'http://my.frigate',
|
|
||||||
camera_name: 'my-camera',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(endpoints).not.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
ui: {
|
|
||||||
url: 'http://my.frigate/cameras/my-camera',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with event media type', () => {
|
|
||||||
it.each([[ViewMediaType.Clip], [ViewMediaType.Snapshot]])(
|
|
||||||
'%s',
|
|
||||||
(mediaType: ViewMediaType) => {
|
|
||||||
const endpoints = createEngine().getCameraEndpoints(
|
|
||||||
createCameraConfig({
|
|
||||||
frigate: {
|
|
||||||
url: 'http://my.frigate',
|
|
||||||
camera_name: 'my-camera',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
media: new TestViewMedia({ mediaType: mediaType }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(endpoints).not.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
ui: {
|
|
||||||
url: 'http://my.frigate/events?camera=my-camera',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with recording media type', () => {
|
|
||||||
it('with start time', () => {
|
|
||||||
const startTime = new Date('2023-10-07T16:42:00');
|
|
||||||
const endpoints = createEngine().getCameraEndpoints(
|
|
||||||
createCameraConfig({
|
|
||||||
frigate: {
|
|
||||||
url: 'http://my.frigate',
|
|
||||||
camera_name: 'my-camera',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
media: new TestViewMedia({
|
|
||||||
mediaType: ViewMediaType.Recording,
|
|
||||||
startTime: startTime,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(endpoints).not.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
ui: {
|
|
||||||
url: 'http://my.frigate/recording/my-camera/2023-10-07/16',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('without start time', () => {
|
|
||||||
const endpoints = createEngine().getCameraEndpoints(
|
|
||||||
createCameraConfig({
|
|
||||||
frigate: {
|
|
||||||
url: 'http://my.frigate',
|
|
||||||
camera_name: 'my-camera',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
media: new TestViewMedia({ mediaType: ViewMediaType.Recording }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(endpoints).not.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
ui: {
|
|
||||||
url: 'http://my.frigate/recording/my-camera/',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with view', () => {
|
|
||||||
it('live', () => {
|
|
||||||
const endpoints = createEngine().getCameraEndpoints(
|
|
||||||
createCameraConfig({
|
|
||||||
frigate: {
|
|
||||||
url: 'http://my.frigate',
|
|
||||||
camera_name: 'my-camera',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
view: 'live',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(endpoints).not.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
ui: {
|
|
||||||
url: 'http://my.frigate/cameras/my-camera',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['clip' as const],
|
|
||||||
['clips' as const],
|
|
||||||
['snapshot' as const],
|
|
||||||
['snapshots' as const],
|
|
||||||
])('%s', (viewName: AdvancedCameraCardView) => {
|
|
||||||
const endpoints = createEngine().getCameraEndpoints(
|
|
||||||
createCameraConfig({
|
|
||||||
frigate: {
|
|
||||||
url: 'http://my.frigate',
|
|
||||||
camera_name: 'my-camera',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
view: viewName,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(endpoints).not.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
ui: {
|
|
||||||
url: 'http://my.frigate/events?camera=my-camera',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([['recording' as const], ['recordings' as const]])(
|
|
||||||
'%s',
|
|
||||||
(viewName: AdvancedCameraCardView) => {
|
|
||||||
const endpoints = createEngine().getCameraEndpoints(
|
|
||||||
createCameraConfig({
|
|
||||||
frigate: {
|
|
||||||
url: 'http://my.frigate',
|
|
||||||
camera_name: 'my-camera',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
view: viewName,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(endpoints).not.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
ui: {
|
|
||||||
url: 'http://my.frigate/recording/my-camera/',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -295,21 +295,26 @@ describe('GenericCameraManagerEngine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('should get camera endpoints', () => {
|
describe('should get camera endpoints', () => {
|
||||||
it('default', () => {
|
it('default', async () => {
|
||||||
expect(createEngine().getCameraEndpoints(createGenericCameraConfig())).toBeNull();
|
const camera = await createEngine().createCamera(
|
||||||
|
createHASS(),
|
||||||
|
createGenericCameraConfig(),
|
||||||
|
);
|
||||||
|
expect(camera.getEndpoints()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('for go2rtc', () => {
|
it('for go2rtc', async () => {
|
||||||
expect(
|
const camera = await createEngine().createCamera(
|
||||||
createEngine().getCameraEndpoints(
|
createHASS(),
|
||||||
createGenericCameraConfig({
|
createGenericCameraConfig({
|
||||||
go2rtc: {
|
go2rtc: {
|
||||||
stream: 'stream',
|
stream: 'stream',
|
||||||
url: '/local/path',
|
url: '/local/path',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
).toEqual({
|
|
||||||
|
expect(camera.getEndpoints()).toEqual({
|
||||||
go2rtc: {
|
go2rtc: {
|
||||||
endpoint: '/local/path/api/ws?src=stream',
|
endpoint: '/local/path/api/ws?src=stream',
|
||||||
sign: true,
|
sign: true,
|
||||||
@@ -317,14 +322,15 @@ describe('GenericCameraManagerEngine', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('for webrtc-card', () => {
|
it('for webrtc-card', async () => {
|
||||||
expect(
|
const camera = await createEngine().createCamera(
|
||||||
createEngine().getCameraEndpoints(
|
createHASS(),
|
||||||
createGenericCameraConfig({
|
createGenericCameraConfig({
|
||||||
camera_entity: 'camera.office',
|
camera_entity: 'camera.office',
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
).toEqual({
|
|
||||||
|
expect(camera.getEndpoints()).toEqual({
|
||||||
webrtcCard: {
|
webrtcCard: {
|
||||||
endpoint: 'camera.office',
|
endpoint: 'camera.office',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { ViewFolder, ViewItem, ViewMedia } from '../../src/view/item.js';
|
|||||||
import { ViewItemCapabilities } from '../../src/view/types.js';
|
import { ViewItemCapabilities } from '../../src/view/types.js';
|
||||||
import {
|
import {
|
||||||
TestViewMedia,
|
TestViewMedia,
|
||||||
createCamera,
|
createInitializedCamera,
|
||||||
createCameraConfig,
|
createCameraConfig,
|
||||||
createCapabilities,
|
createCapabilities,
|
||||||
createCardAPI,
|
createCardAPI,
|
||||||
@@ -253,7 +253,7 @@ describe('CameraManager', async () => {
|
|||||||
if (engineType) {
|
if (engineType) {
|
||||||
vi.mocked(mockEngine.createCamera).mockImplementationOnce(
|
vi.mocked(mockEngine.createCamera).mockImplementationOnce(
|
||||||
async (_hass: HomeAssistant, cameraConfig: CameraConfig): Promise<Camera> =>
|
async (_hass: HomeAssistant, cameraConfig: CameraConfig): Promise<Camera> =>
|
||||||
createCamera(
|
await createInitializedCamera(
|
||||||
cameraConfig,
|
cameraConfig,
|
||||||
mockEngine,
|
mockEngine,
|
||||||
camera.capabilties ?? createCapabilities(),
|
camera.capabilties ?? createCapabilities(),
|
||||||
@@ -984,12 +984,7 @@ describe('CameraManager', async () => {
|
|||||||
|
|
||||||
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
|
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
|
||||||
|
|
||||||
const result: CameraEndpoints = {};
|
expect(manager.getCameraEndpoints('id', { view: 'live' })).toBeDefined();
|
||||||
const context: CameraEndpointsContext = {};
|
|
||||||
vi.mocked(engine.getCameraEndpoints).mockReturnValue(result);
|
|
||||||
|
|
||||||
expect(manager.getCameraEndpoints('id', context)).toBe(result);
|
|
||||||
expect(engine.getCameraEndpoints).toBeCalledWith(expect.anything(), context);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import { MotionEyeCameraManagerEngine } from '../../../src/camera-manager/motioneye/engine-motioneye';
|
||||||
|
import { CameraManagerRequestCache, Engine } from '../../../src/camera-manager/types';
|
||||||
|
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
||||||
|
import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker';
|
||||||
|
import { ResolvedMediaCache } from '../../../src/ha/resolved-media';
|
||||||
|
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||||
|
|
||||||
|
const createEngine = (): MotionEyeCameraManagerEngine => {
|
||||||
|
return new MotionEyeCameraManagerEngine(
|
||||||
|
new EntityRegistryManagerMock(),
|
||||||
|
mock<StateWatcher>(),
|
||||||
|
new BrowseMediaWalker(),
|
||||||
|
new ResolvedMediaCache(),
|
||||||
|
new CameraManagerRequestCache(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('MotionEyeCameraManagerEngine', () => {
|
||||||
|
it('should get correct engine type', () => {
|
||||||
|
const engine = createEngine();
|
||||||
|
expect(engine.getEngineType()).toBe(Engine.MotionEye);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,7 +32,7 @@ import { ResolvedMediaCache } from '../../../src/ha/resolved-media';
|
|||||||
import { homeAssistantWSRequest } from '../../../src/ha/ws-request';
|
import { homeAssistantWSRequest } from '../../../src/ha/ws-request';
|
||||||
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
|
||||||
import {
|
import {
|
||||||
createCamera,
|
createInitializedCamera,
|
||||||
createCameraConfig,
|
createCameraConfig,
|
||||||
createHASS,
|
createHASS,
|
||||||
createRegistryEntity,
|
createRegistryEntity,
|
||||||
@@ -254,15 +254,11 @@ describe('ReolinkCameraManagerEngine', () => {
|
|||||||
expect(camera.getConfig()).toBe(config);
|
expect(camera.getConfig()).toBe(config);
|
||||||
expect(camera.getEngine()).toBe(engine);
|
expect(camera.getEngine()).toBe(engine);
|
||||||
expect(camera.getCapabilities()?.getRawCapabilities()).toEqual({
|
expect(camera.getCapabilities()?.getRawCapabilities()).toEqual({
|
||||||
'favorite-events': false,
|
'2-way-audio': false,
|
||||||
'favorite-recordings': false,
|
|
||||||
clips: true,
|
clips: true,
|
||||||
'remote-control-entity': true,
|
'remote-control-entity': true,
|
||||||
live: true,
|
live: true,
|
||||||
menu: true,
|
menu: true,
|
||||||
recordings: false,
|
|
||||||
seek: false,
|
|
||||||
snapshots: false,
|
|
||||||
substream: true,
|
substream: true,
|
||||||
trigger: true,
|
trigger: true,
|
||||||
});
|
});
|
||||||
@@ -287,32 +283,39 @@ describe('ReolinkCameraManagerEngine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('should get camera endpoints', () => {
|
describe('should get camera endpoints', () => {
|
||||||
it('should return ui endpoint', () => {
|
it('should return ui endpoint', async () => {
|
||||||
const cameraConfig = createCameraConfig({
|
const engine = createPopulatedEngine();
|
||||||
reolink: {
|
const camera = await engine.createCamera(
|
||||||
url: 'http://path-to-reolink',
|
createHASS(),
|
||||||
},
|
createCameraConfig({
|
||||||
});
|
camera_entity: 'camera.office',
|
||||||
|
reolink: {
|
||||||
|
url: 'http://path-to-reolink',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const engine = createEngine();
|
expect(camera.getEndpoints()).toEqual(
|
||||||
expect(engine.getCameraEndpoints(cameraConfig)).toEqual(
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ui: { endpoint: 'http://path-to-reolink' },
|
ui: { endpoint: 'http://path-to-reolink' },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return go2rtc endpoint', () => {
|
it('should return go2rtc endpoint', async () => {
|
||||||
const cameraConfig = createCameraConfig({
|
const engine = createPopulatedEngine();
|
||||||
go2rtc: {
|
const camera = await engine.createCamera(
|
||||||
url: 'http://path-to-go2rtc',
|
createHASS(),
|
||||||
stream: 'stream',
|
createCameraConfig({
|
||||||
},
|
camera_entity: 'camera.office',
|
||||||
});
|
go2rtc: {
|
||||||
|
url: 'http://path-to-go2rtc',
|
||||||
|
stream: 'stream',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const engine = createEngine();
|
expect(camera.getEndpoints()).toEqual(
|
||||||
|
|
||||||
expect(engine.getCameraEndpoints(cameraConfig)).toEqual(
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
go2rtc: { endpoint: 'http://path-to-go2rtc/api/ws?src=stream', sign: false },
|
go2rtc: { endpoint: 'http://path-to-go2rtc/api/ws?src=stream', sign: false },
|
||||||
}),
|
}),
|
||||||
@@ -629,7 +632,9 @@ describe('ReolinkCameraManagerEngine', () => {
|
|||||||
const engine = createPopulatedEngine();
|
const engine = createPopulatedEngine();
|
||||||
|
|
||||||
const store = new CameraManagerStore();
|
const store = new CameraManagerStore();
|
||||||
store.addCamera(createCamera(createCameraConfig({ id: 'office' }), engine));
|
store.addCamera(
|
||||||
|
await createInitializedCamera(createCameraConfig({ id: 'office' }), engine),
|
||||||
|
);
|
||||||
|
|
||||||
const hass = createHASS();
|
const hass = createHASS();
|
||||||
const events = await engine.getEvents(hass, store, {
|
const events = await engine.getEvents(hass, store, {
|
||||||
@@ -1136,7 +1141,9 @@ describe('ReolinkCameraManagerEngine', () => {
|
|||||||
const engine = createPopulatedEngine();
|
const engine = createPopulatedEngine();
|
||||||
|
|
||||||
const store = new CameraManagerStore();
|
const store = new CameraManagerStore();
|
||||||
store.addCamera(createCamera(createCameraConfig({ id: 'office' }), engine));
|
store.addCamera(
|
||||||
|
await createInitializedCamera(createCameraConfig({ id: 'office' }), engine),
|
||||||
|
);
|
||||||
|
|
||||||
const metadata = await engine.getMediaMetadata(createHASS(), store, {
|
const metadata = await engine.getMediaMetadata(createHASS(), store, {
|
||||||
type: QueryType.MediaMetadata,
|
type: QueryType.MediaMetadata,
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-fact
|
|||||||
import { CameraManagerStore } from '../../src/camera-manager/store.js';
|
import { CameraManagerStore } from '../../src/camera-manager/store.js';
|
||||||
import { Engine } from '../../src/camera-manager/types.js';
|
import { Engine } from '../../src/camera-manager/types.js';
|
||||||
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
|
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
|
||||||
|
import { DeviceRegistryManager } from '../../src/ha/registry/device/index.js';
|
||||||
import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js';
|
import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js';
|
||||||
import { ResolvedMediaCache } from '../../src/ha/resolved-media.js';
|
import { ResolvedMediaCache } from '../../src/ha/resolved-media.js';
|
||||||
import { TestViewMedia, createCameraConfig } from '../test-utils.js';
|
import {
|
||||||
|
TestViewMedia,
|
||||||
|
createCameraConfig,
|
||||||
|
createInitializedCamera,
|
||||||
|
} from '../test-utils.js';
|
||||||
|
|
||||||
describe('CameraManagerStore', async () => {
|
describe('CameraManagerStore', async () => {
|
||||||
const configVisible = createCameraConfig({
|
const configVisible = createCameraConfig({
|
||||||
@@ -19,7 +24,10 @@ describe('CameraManagerStore', async () => {
|
|||||||
hide: true,
|
hide: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const engineFactory = new CameraManagerEngineFactory(mock<EntityRegistryManager>());
|
const engineFactory = new CameraManagerEngineFactory(
|
||||||
|
mock<EntityRegistryManager>(),
|
||||||
|
mock<DeviceRegistryManager>(),
|
||||||
|
);
|
||||||
|
|
||||||
const engineGeneric = await engineFactory.createEngine(Engine.Generic, {
|
const engineGeneric = await engineFactory.createEngine(Engine.Generic, {
|
||||||
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
@@ -269,7 +277,7 @@ describe('CameraManagerStore', async () => {
|
|||||||
expect(store.getAllDependentCameras('one')).toEqual(new Set(['one', 'two']));
|
expect(store.getAllDependentCameras('one')).toEqual(new Set(['one', 'two']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return cameras with specific capabilities', () => {
|
it('should return cameras with specific capabilities', async () => {
|
||||||
const store = new CameraManagerStore();
|
const store = new CameraManagerStore();
|
||||||
store.addCamera(
|
store.addCamera(
|
||||||
new Camera(
|
new Camera(
|
||||||
@@ -283,22 +291,18 @@ describe('CameraManagerStore', async () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
store.addCamera(
|
store.addCamera(
|
||||||
new Camera(
|
await createInitializedCamera(
|
||||||
createCameraConfig({
|
createCameraConfig({
|
||||||
id: 'two',
|
id: 'two',
|
||||||
}),
|
}),
|
||||||
engineGeneric,
|
engineGeneric,
|
||||||
{
|
new Capabilities({ clips: true }),
|
||||||
capabilities: new Capabilities({
|
|
||||||
clips: true,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(store.getAllDependentCameras('one', 'clips')).toEqual(new Set(['two']));
|
expect(store.getAllDependentCameras('one', 'clips')).toEqual(new Set(['two']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return cameras with specific capabilities inclusive of parent', () => {
|
it('should return cameras with specific capabilities inclusive of parent', async () => {
|
||||||
const store = new CameraManagerStore();
|
const store = new CameraManagerStore();
|
||||||
store.addCamera(
|
store.addCamera(
|
||||||
new Camera(
|
new Camera(
|
||||||
@@ -312,16 +316,12 @@ describe('CameraManagerStore', async () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
store.addCamera(
|
store.addCamera(
|
||||||
new Camera(
|
await createInitializedCamera(
|
||||||
createCameraConfig({
|
createCameraConfig({
|
||||||
id: 'two',
|
id: 'two',
|
||||||
}),
|
}),
|
||||||
engineGeneric,
|
engineGeneric,
|
||||||
{
|
new Capabilities({ clips: true }),
|
||||||
capabilities: new Capabilities({
|
|
||||||
clips: true,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(store.getAllDependentCameras('one', 'clips', { inclusive: true })).toEqual(
|
expect(store.getAllDependentCameras('one', 'clips', { inclusive: true })).toEqual(
|
||||||
@@ -330,19 +330,15 @@ describe('CameraManagerStore', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getCameraIDsWithCapability', () => {
|
it('getCameraIDsWithCapability', async () => {
|
||||||
const store = new CameraManagerStore();
|
const store = new CameraManagerStore();
|
||||||
store.addCamera(
|
store.addCamera(
|
||||||
new Camera(
|
await createInitializedCamera(
|
||||||
createCameraConfig({
|
createCameraConfig({
|
||||||
id: 'one',
|
id: 'one',
|
||||||
}),
|
}),
|
||||||
engineGeneric,
|
engineGeneric,
|
||||||
{
|
new Capabilities({ clips: true }),
|
||||||
capabilities: new Capabilities({
|
|
||||||
clips: true,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
store.addCamera(
|
store.addCamera(
|
||||||
|
|||||||
@@ -45,15 +45,10 @@ describe('TPLinkCameraManagerEngine', () => {
|
|||||||
expect(camera.getConfig()).toBe(config);
|
expect(camera.getConfig()).toBe(config);
|
||||||
expect(camera.getEngine()).toBe(engine);
|
expect(camera.getEngine()).toBe(engine);
|
||||||
expect(camera.getCapabilities()?.getRawCapabilities()).toEqual({
|
expect(camera.getCapabilities()?.getRawCapabilities()).toEqual({
|
||||||
'favorite-events': false,
|
'2-way-audio': false,
|
||||||
'favorite-recordings': false,
|
|
||||||
clips: false,
|
|
||||||
'remote-control-entity': true,
|
'remote-control-entity': true,
|
||||||
live: true,
|
live: true,
|
||||||
menu: true,
|
menu: true,
|
||||||
recordings: false,
|
|
||||||
seek: false,
|
|
||||||
snapshots: false,
|
|
||||||
substream: true,
|
substream: true,
|
||||||
trigger: true,
|
trigger: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { getDefaultGo2RTCEndpoint } from '../../../src/camera-manager/utils/go2rtc-endpoint.js';
|
import {
|
||||||
|
getGo2RTCMetadataEndpoint,
|
||||||
|
getGo2RTCStreamEndpoint,
|
||||||
|
} from '../../../src/camera-manager/utils/go2rtc/endpoint.js';
|
||||||
import { createCameraConfig } from '../../test-utils.js';
|
import { createCameraConfig } from '../../test-utils.js';
|
||||||
|
|
||||||
describe('getDefaultGo2RTCEndpoint', () => {
|
describe('getGo2RTCStreamEndpoint', () => {
|
||||||
it('with local configuration', () => {
|
it('with local configuration', () => {
|
||||||
expect(
|
expect(
|
||||||
getDefaultGo2RTCEndpoint(
|
getGo2RTCStreamEndpoint(
|
||||||
createCameraConfig({
|
createCameraConfig({
|
||||||
go2rtc: {
|
go2rtc: {
|
||||||
stream: 'stream',
|
stream: 'stream',
|
||||||
@@ -21,7 +24,7 @@ describe('getDefaultGo2RTCEndpoint', () => {
|
|||||||
|
|
||||||
it('with remote configuration', () => {
|
it('with remote configuration', () => {
|
||||||
expect(
|
expect(
|
||||||
getDefaultGo2RTCEndpoint(
|
getGo2RTCStreamEndpoint(
|
||||||
createCameraConfig({
|
createCameraConfig({
|
||||||
go2rtc: {
|
go2rtc: {
|
||||||
stream: 'stream',
|
stream: 'stream',
|
||||||
@@ -36,6 +39,45 @@ describe('getDefaultGo2RTCEndpoint', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('without configuration', () => {
|
it('without configuration', () => {
|
||||||
expect(getDefaultGo2RTCEndpoint(createCameraConfig())).toBeNull();
|
expect(getGo2RTCStreamEndpoint(createCameraConfig())).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGo2RTCMetadataEndpoint', () => {
|
||||||
|
it('with local configuration', () => {
|
||||||
|
expect(
|
||||||
|
getGo2RTCMetadataEndpoint(
|
||||||
|
createCameraConfig({
|
||||||
|
go2rtc: {
|
||||||
|
stream: 'stream',
|
||||||
|
url: '/local/path',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
endpoint: '/local/path/api/streams?src=stream&video=all&audio=allµphone',
|
||||||
|
sign: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with remote configuration', () => {
|
||||||
|
expect(
|
||||||
|
getGo2RTCMetadataEndpoint(
|
||||||
|
createCameraConfig({
|
||||||
|
go2rtc: {
|
||||||
|
stream: 'stream',
|
||||||
|
url: 'https://my-custom-go2rtc',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
endpoint:
|
||||||
|
'https://my-custom-go2rtc/api/streams?src=stream&video=all&audio=allµphone',
|
||||||
|
sign: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without configuration', () => {
|
||||||
|
expect(getGo2RTCMetadataEndpoint(createCameraConfig())).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -961,19 +961,23 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('should have microphone button', () => {
|
describe('should have microphone button', () => {
|
||||||
it('with suitable loaded media', () => {
|
it('when camera has 2-way-audio capability', () => {
|
||||||
const microphoneManager = mock<MicrophoneManager>();
|
const microphoneManager = mock<MicrophoneManager>();
|
||||||
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
|
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
|
||||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
|
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
|
||||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||||
|
|
||||||
const buttons = calculateButtons(controller, {
|
const cameraManager = createCameraManager(
|
||||||
microphoneManager: microphoneManager,
|
createStore([
|
||||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
{
|
||||||
capabilities: {
|
cameraID: 'camera-1',
|
||||||
supports2WayAudio: true,
|
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||||
},
|
},
|
||||||
}),
|
]),
|
||||||
|
);
|
||||||
|
const buttons = calculateButtons(controller, {
|
||||||
|
cameraManager,
|
||||||
|
microphoneManager: microphoneManager,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
@@ -997,19 +1001,18 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('without suitable loaded media', () => {
|
it('when camera does not have 2-way-audio capability', () => {
|
||||||
const microphoneManager = mock<MicrophoneManager>();
|
const microphoneManager = mock<MicrophoneManager>();
|
||||||
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
|
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
|
||||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
|
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
|
||||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||||
|
|
||||||
|
const cameraManager = createCameraManager(
|
||||||
|
createStore([{ cameraID: 'camera-1', capabilities: createCapabilities() }]),
|
||||||
|
);
|
||||||
const buttons = calculateButtons(controller, {
|
const buttons = calculateButtons(controller, {
|
||||||
|
cameraManager,
|
||||||
microphoneManager: microphoneManager,
|
microphoneManager: microphoneManager,
|
||||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
|
||||||
capabilities: {
|
|
||||||
supports2WayAudio: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).not.toEqual(
|
expect(buttons).not.toEqual(
|
||||||
@@ -1021,13 +1024,17 @@ describe('MenuButtonController', () => {
|
|||||||
const microphoneManager = mock<MicrophoneManager>();
|
const microphoneManager = mock<MicrophoneManager>();
|
||||||
vi.mocked(microphoneManager.isForbidden).mockReturnValue(true);
|
vi.mocked(microphoneManager.isForbidden).mockReturnValue(true);
|
||||||
|
|
||||||
const buttons = calculateButtons(controller, {
|
const cameraManager = createCameraManager(
|
||||||
microphoneManager: microphoneManager,
|
createStore([
|
||||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
{
|
||||||
capabilities: {
|
cameraID: 'camera-1',
|
||||||
supports2WayAudio: true,
|
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||||
},
|
},
|
||||||
}),
|
]),
|
||||||
|
);
|
||||||
|
const buttons = calculateButtons(controller, {
|
||||||
|
cameraManager,
|
||||||
|
microphoneManager: microphoneManager,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
@@ -1046,13 +1053,17 @@ describe('MenuButtonController', () => {
|
|||||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(true);
|
vi.mocked(microphoneManager.isMuted).mockReturnValue(true);
|
||||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||||
|
|
||||||
const buttons = calculateButtons(controller, {
|
const cameraManager = createCameraManager(
|
||||||
microphoneManager: microphoneManager,
|
createStore([
|
||||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
{
|
||||||
capabilities: {
|
cameraID: 'camera-1',
|
||||||
supports2WayAudio: true,
|
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||||
},
|
},
|
||||||
}),
|
]),
|
||||||
|
);
|
||||||
|
const buttons = calculateButtons(controller, {
|
||||||
|
cameraManager,
|
||||||
|
microphoneManager: microphoneManager,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
@@ -1079,13 +1090,17 @@ describe('MenuButtonController', () => {
|
|||||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(true);
|
vi.mocked(microphoneManager.isMuted).mockReturnValue(true);
|
||||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(false);
|
vi.mocked(microphoneManager.isSupported).mockReturnValue(false);
|
||||||
|
|
||||||
const buttons = calculateButtons(controller, {
|
const cameraManager = createCameraManager(
|
||||||
microphoneManager: microphoneManager,
|
createStore([
|
||||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
{
|
||||||
capabilities: {
|
cameraID: 'camera-1',
|
||||||
supports2WayAudio: true,
|
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||||
},
|
},
|
||||||
}),
|
]),
|
||||||
|
);
|
||||||
|
const buttons = calculateButtons(controller, {
|
||||||
|
cameraManager,
|
||||||
|
microphoneManager: microphoneManager,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
@@ -1104,13 +1119,17 @@ describe('MenuButtonController', () => {
|
|||||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(true);
|
vi.mocked(microphoneManager.isMuted).mockReturnValue(true);
|
||||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||||
|
|
||||||
const buttons = calculateButtons(controller, {
|
const cameraManager = createCameraManager(
|
||||||
microphoneManager: microphoneManager,
|
createStore([
|
||||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
{
|
||||||
capabilities: {
|
cameraID: 'camera-1',
|
||||||
supports2WayAudio: true,
|
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||||
},
|
},
|
||||||
}),
|
]),
|
||||||
|
);
|
||||||
|
const buttons = calculateButtons(controller, {
|
||||||
|
cameraManager,
|
||||||
|
microphoneManager: microphoneManager,
|
||||||
config: createConfig({
|
config: createConfig({
|
||||||
menu: { buttons: { microphone: { type: 'toggle' } } },
|
menu: { buttons: { microphone: { type: 'toggle' } } },
|
||||||
}),
|
}),
|
||||||
@@ -1136,13 +1155,17 @@ describe('MenuButtonController', () => {
|
|||||||
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
|
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
|
||||||
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
|
||||||
|
|
||||||
const buttons = calculateButtons(controller, {
|
const cameraManager = createCameraManager(
|
||||||
microphoneManager: microphoneManager,
|
createStore([
|
||||||
currentMediaLoadedInfo: createMediaLoadedInfo({
|
{
|
||||||
capabilities: {
|
cameraID: 'camera-1',
|
||||||
supports2WayAudio: true,
|
capabilities: createCapabilities({ '2-way-audio': true }),
|
||||||
},
|
},
|
||||||
}),
|
]),
|
||||||
|
);
|
||||||
|
const buttons = calculateButtons(controller, {
|
||||||
|
cameraManager,
|
||||||
|
microphoneManager: microphoneManager,
|
||||||
config: createConfig({
|
config: createConfig({
|
||||||
menu: { buttons: { microphone: { type: 'toggle' } } },
|
menu: { buttons: { microphone: { type: 'toggle' } } },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { homeAssistantSignAndFetch } from '../../src/ha/fetch';
|
||||||
|
import { homeAssistantSignPath } from '../../src/ha/sign-path';
|
||||||
|
import { AdvancedCameraCardError, Endpoint } from '../../src/types';
|
||||||
|
import { createHASS } from '../test-utils';
|
||||||
|
|
||||||
|
vi.mock('../../src/ha/sign-path');
|
||||||
|
|
||||||
|
describe('homeAssistantSignAndFetch', () => {
|
||||||
|
const response = {
|
||||||
|
val: 10,
|
||||||
|
};
|
||||||
|
const schema = z.object({
|
||||||
|
val: z.number(),
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
vi.mocked(homeAssistantSignPath).mockResolvedValue('http://signed');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return parsed data on successful call with endpoint', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => response,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||||
|
expect(await homeAssistantSignAndFetch(createHASS(), endpoint, schema)).toEqual(
|
||||||
|
response,
|
||||||
|
);
|
||||||
|
expect(homeAssistantSignPath).not.toHaveBeenCalled();
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('http://example.com', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass timeout signal when timeoutSeconds is provided', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => response,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||||
|
expect(
|
||||||
|
await homeAssistantSignAndFetch(createHASS(), endpoint, schema, {
|
||||||
|
timeoutSeconds: 5,
|
||||||
|
}),
|
||||||
|
).toEqual(response);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('http://example.com', {
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sign path if requested', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => response,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endpoint: Endpoint = {
|
||||||
|
endpoint: 'http://example.com',
|
||||||
|
sign: true,
|
||||||
|
};
|
||||||
|
const hass = createHASS();
|
||||||
|
expect(await homeAssistantSignAndFetch(hass, endpoint, schema)).toEqual(response);
|
||||||
|
expect(homeAssistantSignPath).toHaveBeenCalledWith(hass, 'http://example.com');
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('http://signed', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on sign failure', async () => {
|
||||||
|
vi.mocked(homeAssistantSignPath).mockRejectedValueOnce(new Error('Sign failed'));
|
||||||
|
|
||||||
|
const endpoint: Endpoint = {
|
||||||
|
endpoint: 'http://example.com',
|
||||||
|
sign: true,
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
homeAssistantSignAndFetch(createHASS(), endpoint, schema),
|
||||||
|
).rejects.toThrow(/Could not sign Home Assistant URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if sign path returns null', async () => {
|
||||||
|
vi.mocked(homeAssistantSignPath).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const endpoint: Endpoint = {
|
||||||
|
endpoint: 'http://example.com',
|
||||||
|
sign: true,
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
homeAssistantSignAndFetch(createHASS(), endpoint, schema),
|
||||||
|
).rejects.toThrow(/Could not sign Home Assistant URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on fetch failure', async () => {
|
||||||
|
fetchMock.mockRejectedValueOnce(new Error('Fetch failed'));
|
||||||
|
|
||||||
|
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||||
|
try {
|
||||||
|
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as AdvancedCameraCardError;
|
||||||
|
expect(error.message).toMatch(/Could not fetch URL/);
|
||||||
|
expect(error.context).toEqual({
|
||||||
|
endpoint,
|
||||||
|
error: expect.any(Error),
|
||||||
|
});
|
||||||
|
const context = error.context as { error: Error };
|
||||||
|
expect(context.error.message).toBe('Fetch failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on non-ok response', async () => {
|
||||||
|
const response = {
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
statusText: 'Not Found',
|
||||||
|
} as Response;
|
||||||
|
fetchMock.mockResolvedValueOnce(response);
|
||||||
|
|
||||||
|
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||||
|
try {
|
||||||
|
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as AdvancedCameraCardError).message).toMatch(
|
||||||
|
/Failed to receive response/,
|
||||||
|
);
|
||||||
|
expect((e as AdvancedCameraCardError).context).toEqual({
|
||||||
|
endpoint,
|
||||||
|
response,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on JSON parse failure', async () => {
|
||||||
|
const response = {
|
||||||
|
ok: true,
|
||||||
|
json: async () => {
|
||||||
|
throw new Error('JSON error');
|
||||||
|
},
|
||||||
|
} as unknown as Response;
|
||||||
|
fetchMock.mockResolvedValueOnce(response);
|
||||||
|
|
||||||
|
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||||
|
try {
|
||||||
|
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as AdvancedCameraCardError;
|
||||||
|
expect(error.message).toMatch(/Received invalid response/);
|
||||||
|
expect(error.context).toEqual({
|
||||||
|
endpoint,
|
||||||
|
response,
|
||||||
|
error: expect.any(Error),
|
||||||
|
});
|
||||||
|
const context = error.context as { error: Error };
|
||||||
|
expect(context.error.message).toBe('JSON error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on schema validation failure', async () => {
|
||||||
|
const data = { val: 'string' };
|
||||||
|
const response = {
|
||||||
|
ok: true,
|
||||||
|
json: async () => data,
|
||||||
|
} as unknown as Response;
|
||||||
|
fetchMock.mockResolvedValueOnce(response);
|
||||||
|
|
||||||
|
const endpoint: Endpoint = { endpoint: 'http://example.com' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await homeAssistantSignAndFetch(createHASS(), endpoint, schema);
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as AdvancedCameraCardError).message).toMatch(
|
||||||
|
/Received invalid response/,
|
||||||
|
);
|
||||||
|
expect((e as AdvancedCameraCardError).context).toMatchObject({
|
||||||
|
endpoint,
|
||||||
|
data,
|
||||||
|
error: expect.any(z.ZodError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { CameraProxyConfig } from '../../src/camera-manager/types.js';
|
import { CameraProxyConfig } from '../../src/camera-manager/types.js';
|
||||||
import {
|
import {
|
||||||
addDynamicProxyURL,
|
addDynamicProxyURL,
|
||||||
|
createProxiedEndpointIfNecessary,
|
||||||
getWebProxiedURL,
|
getWebProxiedURL,
|
||||||
shouldUseWebProxy,
|
shouldUseWebProxy,
|
||||||
} from '../../src/ha/web-proxy.js';
|
} from '../../src/ha/web-proxy.js';
|
||||||
@@ -115,3 +116,161 @@ describe('addDynamicProxyURL', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createProxiedEndpointIfNecessary', () => {
|
||||||
|
const createProxyConfig = (
|
||||||
|
config: Partial<CameraProxyConfig> = {},
|
||||||
|
): CameraProxyConfig => ({
|
||||||
|
media: true,
|
||||||
|
live: true,
|
||||||
|
ssl_verification: true,
|
||||||
|
ssl_ciphers: 'default',
|
||||||
|
dynamic: true,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
|
||||||
|
const testEndpoint = { endpoint: 'http://example.com/stream', sign: false };
|
||||||
|
|
||||||
|
it('should return original endpoint when proxyConfig is undefined', async () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.config.components = ['hass_web_proxy'];
|
||||||
|
|
||||||
|
const result = await createProxiedEndpointIfNecessary(hass, testEndpoint);
|
||||||
|
expect(result).toBe(testEndpoint);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original endpoint when proxy is not available', async () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.config.components = [];
|
||||||
|
|
||||||
|
const result = await createProxiedEndpointIfNecessary(
|
||||||
|
hass,
|
||||||
|
testEndpoint,
|
||||||
|
createProxyConfig(),
|
||||||
|
);
|
||||||
|
expect(result).toBe(testEndpoint);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original endpoint when context is not enabled', async () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.config.components = ['hass_web_proxy'];
|
||||||
|
|
||||||
|
const result = await createProxiedEndpointIfNecessary(
|
||||||
|
hass,
|
||||||
|
testEndpoint,
|
||||||
|
createProxyConfig({ media: false }),
|
||||||
|
{ context: 'media' },
|
||||||
|
);
|
||||||
|
expect(result).toBe(testEndpoint);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return proxied endpoint with dynamic registration', async () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.config.components = ['hass_web_proxy'];
|
||||||
|
|
||||||
|
const result = await createProxiedEndpointIfNecessary(
|
||||||
|
hass,
|
||||||
|
testEndpoint,
|
||||||
|
createProxyConfig(),
|
||||||
|
{ context: 'media', ttl: 300, openLimit: 5 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hass.callService).toHaveBeenCalledWith(
|
||||||
|
'hass_web_proxy',
|
||||||
|
'create_proxied_url',
|
||||||
|
expect.objectContaining({
|
||||||
|
url_pattern: 'http://example.com/stream',
|
||||||
|
ttl: 300,
|
||||||
|
open_limit: 5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
endpoint: '/api/hass_web_proxy/v0/?url=http%3A%2F%2Fexample.com%2Fstream',
|
||||||
|
sign: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should strip hash fragment when registering dynamic proxy', async () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.config.components = ['hass_web_proxy'];
|
||||||
|
|
||||||
|
const endpointWithHash = {
|
||||||
|
endpoint: 'http://example.com/stream#fragment',
|
||||||
|
sign: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await createProxiedEndpointIfNecessary(hass, endpointWithHash, createProxyConfig());
|
||||||
|
|
||||||
|
expect(hass.callService).toHaveBeenCalledWith(
|
||||||
|
'hass_web_proxy',
|
||||||
|
'create_proxied_url',
|
||||||
|
expect.objectContaining({
|
||||||
|
url_pattern: 'http://example.com/stream',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return proxied endpoint without dynamic registration', async () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.config.components = ['hass_web_proxy'];
|
||||||
|
|
||||||
|
const result = await createProxiedEndpointIfNecessary(
|
||||||
|
hass,
|
||||||
|
testEndpoint,
|
||||||
|
createProxyConfig({ dynamic: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hass.callService).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({
|
||||||
|
endpoint: '/api/hass_web_proxy/v0/?url=http%3A%2F%2Fexample.com%2Fstream',
|
||||||
|
sign: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return websocket proxied endpoint', async () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.config.components = ['hass_web_proxy'];
|
||||||
|
|
||||||
|
const result = await createProxiedEndpointIfNecessary(
|
||||||
|
hass,
|
||||||
|
testEndpoint,
|
||||||
|
createProxyConfig({ dynamic: false }),
|
||||||
|
{ websocket: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
endpoint: '/api/hass_web_proxy/v0/ws?url=http%3A%2F%2Fexample.com%2Fstream',
|
||||||
|
sign: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use live context when specified', async () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.config.components = ['hass_web_proxy'];
|
||||||
|
|
||||||
|
const result = await createProxiedEndpointIfNecessary(
|
||||||
|
hass,
|
||||||
|
testEndpoint,
|
||||||
|
createProxyConfig({ media: false, live: true }),
|
||||||
|
{ context: 'live' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.endpoint).toContain('/api/hass_web_proxy/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default openLimit to 0 when not specified', async () => {
|
||||||
|
const hass = createHASS();
|
||||||
|
hass.config.components = ['hass_web_proxy'];
|
||||||
|
|
||||||
|
await createProxiedEndpointIfNecessary(hass, testEndpoint, createProxyConfig());
|
||||||
|
|
||||||
|
expect(hass.callService).toHaveBeenCalledWith(
|
||||||
|
'hass_web_proxy',
|
||||||
|
'create_proxied_url',
|
||||||
|
expect.objectContaining({
|
||||||
|
open_limit: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+14
-10
@@ -88,12 +88,18 @@ export const createConfig = (
|
|||||||
return advancedCameraCardConfigSchema.parse(createRawConfig(config));
|
return advancedCameraCardConfigSchema.parse(createRawConfig(config));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createCamera = (
|
export const createInitializedCamera = async (
|
||||||
config: CameraConfig,
|
config: CameraConfig,
|
||||||
engine: CameraManagerEngine,
|
engine: CameraManagerEngine,
|
||||||
capabilities?: Capabilities,
|
capabilities?: Capabilities,
|
||||||
): Camera => {
|
): Promise<Camera> => {
|
||||||
return new Camera(config, engine, { capabilities: capabilities });
|
const camera = new Camera(config, engine);
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
...(capabilities ? { capabilityOptions: { capabilities } } : {}),
|
||||||
|
});
|
||||||
|
return camera;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createHASS = (states?: HassEntities, user?: CurrentUser): HomeAssistant => {
|
export const createHASS = (states?: HassEntities, user?: CurrentUser): HomeAssistant => {
|
||||||
@@ -215,6 +221,10 @@ export const createStore = (
|
|||||||
const store = new CameraManagerStore();
|
const store = new CameraManagerStore();
|
||||||
for (const cameraProps of cameras ?? []) {
|
for (const cameraProps of cameras ?? []) {
|
||||||
const eventCallback = cameraProps.eventCallback ?? vi.fn();
|
const eventCallback = cameraProps.eventCallback ?? vi.fn();
|
||||||
|
const capabilities =
|
||||||
|
cameraProps.capabilities === undefined
|
||||||
|
? createCapabilities()
|
||||||
|
: cameraProps.capabilities ?? undefined;
|
||||||
const camera = new Camera(
|
const camera = new Camera(
|
||||||
cameraProps.config ?? createCameraConfig(),
|
cameraProps.config ?? createCameraConfig(),
|
||||||
cameraProps.engine ??
|
cameraProps.engine ??
|
||||||
@@ -222,13 +232,7 @@ export const createStore = (
|
|||||||
mock<StateWatcherSubscriptionInterface>(),
|
mock<StateWatcherSubscriptionInterface>(),
|
||||||
eventCallback,
|
eventCallback,
|
||||||
),
|
),
|
||||||
{
|
{ eventCallback, capabilities },
|
||||||
capabilities:
|
|
||||||
cameraProps.capabilities === undefined
|
|
||||||
? createCapabilities()
|
|
||||||
: cameraProps.capabilities ?? undefined,
|
|
||||||
eventCallback: eventCallback,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
camera.setID(cameraProps.cameraID);
|
camera.setID(cameraProps.cameraID);
|
||||||
store.addCamera(camera);
|
store.addCamera(camera);
|
||||||
|
|||||||
+270
-3
@@ -1,5 +1,11 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { AudioProperties, mayHaveAudio } from '../../src/utils/audio';
|
import {
|
||||||
|
addAudioTracksMuteStateListener,
|
||||||
|
AudioProperties,
|
||||||
|
has2WayAudio,
|
||||||
|
hasAudio,
|
||||||
|
mayHaveAudio,
|
||||||
|
} from '../../src/utils/audio';
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
describe('mayHaveAudio', () => {
|
describe('mayHaveAudio', () => {
|
||||||
@@ -9,9 +15,9 @@ describe('mayHaveAudio', () => {
|
|||||||
expect(mayHaveAudio(element)).toBeTruthy();
|
expect(mayHaveAudio(element)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not detect audio when mozHasAudio undefined', () => {
|
it('should not detect audio when mozHasAudio false', () => {
|
||||||
const element: HTMLVideoElement & AudioProperties = document.createElement('video');
|
const element: HTMLVideoElement & AudioProperties = document.createElement('video');
|
||||||
element.mozHasAudio = undefined;
|
element.mozHasAudio = false;
|
||||||
expect(mayHaveAudio(element)).toBeFalsy();
|
expect(mayHaveAudio(element)).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,3 +70,264 @@ describe('mayHaveAudio', () => {
|
|||||||
expect(mayHaveAudio(element)).toBeTruthy();
|
expect(mayHaveAudio(element)).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hasAudio', () => {
|
||||||
|
const createMockVideo = (): HTMLVideoElement & AudioProperties => {
|
||||||
|
return {} as HTMLVideoElement & AudioProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMockReceiver = (trackKind: string, muted = false): RTCRtpReceiver => {
|
||||||
|
return {
|
||||||
|
track: { kind: trackKind, muted },
|
||||||
|
} as unknown as RTCRtpReceiver;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMockPeerConnection = (receivers: RTCRtpReceiver[]): RTCPeerConnection => {
|
||||||
|
return {
|
||||||
|
getReceivers: () => receivers,
|
||||||
|
} as unknown as RTCPeerConnection;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('WebRTC receiver detection', () => {
|
||||||
|
it('should detect audio when there is an unmuted audio receiver', () => {
|
||||||
|
const pc = createMockPeerConnection([
|
||||||
|
createMockReceiver('video'),
|
||||||
|
createMockReceiver('audio', false),
|
||||||
|
]);
|
||||||
|
expect(hasAudio(createMockVideo(), pc, '')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not detect audio when audio receiver is muted', () => {
|
||||||
|
const pc = createMockPeerConnection([
|
||||||
|
createMockReceiver('video'),
|
||||||
|
createMockReceiver('audio', true),
|
||||||
|
]);
|
||||||
|
expect(hasAudio(createMockVideo(), pc, '')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not detect audio when only video receivers exist', () => {
|
||||||
|
const pc = createMockPeerConnection([createMockReceiver('video')]);
|
||||||
|
expect(hasAudio(createMockVideo(), pc, '')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to mayHaveAudio when no receivers yet', () => {
|
||||||
|
const pc = createMockPeerConnection([]);
|
||||||
|
// Empty receivers means connection not established, falls back to mayHaveAudio
|
||||||
|
// With no properties set on video, mayHaveAudio returns true (generous default)
|
||||||
|
expect(hasAudio(createMockVideo(), pc, '')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MSE codec detection', () => {
|
||||||
|
it('should detect audio when mseCodecs contains mp4a', () => {
|
||||||
|
expect(hasAudio(createMockVideo(), null, 'avc1.640029,mp4a.40.2')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect audio when mseCodecs contains opus', () => {
|
||||||
|
expect(hasAudio(createMockVideo(), null, 'avc1.640029,opus')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect audio when mseCodecs contains flac', () => {
|
||||||
|
expect(hasAudio(createMockVideo(), null, 'avc1.640029,flac')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not detect audio when mseCodecs contains only video codecs', () => {
|
||||||
|
expect(hasAudio(createMockVideo(), null, 'avc1.640029,hvc1.1.6.L153.B0')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fallback to mayHaveAudio', () => {
|
||||||
|
it('should fall back to mayHaveAudio when no SDP or mseCodecs', () => {
|
||||||
|
// With no properties set, mayHaveAudio returns true (generous default)
|
||||||
|
expect(hasAudio(createMockVideo(), null, '')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use mayHaveAudio when mozHasAudio is false', () => {
|
||||||
|
const video = createMockVideo();
|
||||||
|
video.mozHasAudio = false;
|
||||||
|
expect(hasAudio(video, null, '')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('has2WayAudio', () => {
|
||||||
|
const createMockTransceiver = (
|
||||||
|
trackKind: string | null,
|
||||||
|
direction: RTCRtpTransceiverDirection,
|
||||||
|
): RTCRtpTransceiver => {
|
||||||
|
return {
|
||||||
|
sender: {
|
||||||
|
track: trackKind ? { kind: trackKind } : null,
|
||||||
|
},
|
||||||
|
direction,
|
||||||
|
} as unknown as RTCRtpTransceiver;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMockPeerConnection = (
|
||||||
|
transceivers: RTCRtpTransceiver[],
|
||||||
|
): RTCPeerConnection => {
|
||||||
|
return {
|
||||||
|
getTransceivers: () => transceivers,
|
||||||
|
} as unknown as RTCPeerConnection;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return false for null peer connection', () => {
|
||||||
|
expect(has2WayAudio(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when no transceivers', () => {
|
||||||
|
const pc = createMockPeerConnection([]);
|
||||||
|
expect(has2WayAudio(pc)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when audio transceiver is sendonly', () => {
|
||||||
|
const pc = createMockPeerConnection([createMockTransceiver('audio', 'sendonly')]);
|
||||||
|
expect(has2WayAudio(pc)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when audio transceiver is sendrecv', () => {
|
||||||
|
const pc = createMockPeerConnection([createMockTransceiver('audio', 'sendrecv')]);
|
||||||
|
expect(has2WayAudio(pc)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when audio transceiver is recvonly', () => {
|
||||||
|
const pc = createMockPeerConnection([createMockTransceiver('audio', 'recvonly')]);
|
||||||
|
expect(has2WayAudio(pc)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when audio transceiver is inactive', () => {
|
||||||
|
const pc = createMockPeerConnection([createMockTransceiver('audio', 'inactive')]);
|
||||||
|
expect(has2WayAudio(pc)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when only video transceiver with sendonly', () => {
|
||||||
|
const pc = createMockPeerConnection([createMockTransceiver('video', 'sendonly')]);
|
||||||
|
expect(has2WayAudio(pc)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when transceiver has no track', () => {
|
||||||
|
const pc = createMockPeerConnection([createMockTransceiver(null, 'sendonly')]);
|
||||||
|
expect(has2WayAudio(pc)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when mixed transceivers include sendonly audio', () => {
|
||||||
|
const pc = createMockPeerConnection([
|
||||||
|
createMockTransceiver('video', 'recvonly'),
|
||||||
|
createMockTransceiver('audio', 'recvonly'),
|
||||||
|
createMockTransceiver('audio', 'sendonly'),
|
||||||
|
]);
|
||||||
|
expect(has2WayAudio(pc)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addAudioTracksMuteStateListener', () => {
|
||||||
|
interface MockTrack {
|
||||||
|
kind: string;
|
||||||
|
muted: boolean;
|
||||||
|
listeners: Map<string, Set<() => void>>;
|
||||||
|
addEventListener: (event: string, handler: () => void) => void;
|
||||||
|
removeEventListener: (event: string, handler: () => void) => void;
|
||||||
|
triggerEvent: (event: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMockTrack = (kind: string, muted: boolean): MockTrack => {
|
||||||
|
const listeners = new Map<string, Set<() => void>>();
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
muted,
|
||||||
|
listeners,
|
||||||
|
addEventListener: (event, handler) => {
|
||||||
|
if (!listeners.has(event)) {
|
||||||
|
listeners.set(event, new Set());
|
||||||
|
}
|
||||||
|
listeners.get(event)?.add(handler);
|
||||||
|
},
|
||||||
|
removeEventListener: (event, handler) => {
|
||||||
|
listeners.get(event)?.delete(handler);
|
||||||
|
},
|
||||||
|
triggerEvent: (event) => {
|
||||||
|
listeners.get(event)?.forEach((h) => h());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMockPeerConnection = (tracks: MockTrack[]): RTCPeerConnection => {
|
||||||
|
return {
|
||||||
|
getReceivers: () => tracks.map((t) => ({ track: t })),
|
||||||
|
} as unknown as RTCPeerConnection;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return null for null peer connection', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
expect(addAudioTracksMuteStateListener(null, callback)).toBe(null);
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when no audio tracks', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
const pc = createMockPeerConnection([createMockTrack('video', false)]);
|
||||||
|
expect(addAudioTracksMuteStateListener(pc, callback)).toBe(null);
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call callback with true when any track unmutes', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
const track1 = createMockTrack('audio', true);
|
||||||
|
const track2 = createMockTrack('audio', true);
|
||||||
|
const pc = createMockPeerConnection([track1, track2]);
|
||||||
|
|
||||||
|
addAudioTracksMuteStateListener(pc, callback);
|
||||||
|
|
||||||
|
// Unmute first track - now has audio
|
||||||
|
track1.muted = false;
|
||||||
|
track1.triggerEvent('unmute');
|
||||||
|
expect(callback).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call callback with false when all tracks become muted', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
const track1 = createMockTrack('audio', false);
|
||||||
|
const track2 = createMockTrack('audio', false);
|
||||||
|
const pc = createMockPeerConnection([track1, track2]);
|
||||||
|
|
||||||
|
addAudioTracksMuteStateListener(pc, callback);
|
||||||
|
|
||||||
|
// Mute first track - still have an unmuted track, no callback yet
|
||||||
|
track1.muted = true;
|
||||||
|
track1.triggerEvent('mute');
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Mute second track - all muted now
|
||||||
|
track2.muted = true;
|
||||||
|
track2.triggerEvent('mute');
|
||||||
|
expect(callback).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call callback if state has not changed', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
const track = createMockTrack('audio', true);
|
||||||
|
const pc = createMockPeerConnection([track]);
|
||||||
|
|
||||||
|
addAudioTracksMuteStateListener(pc, callback);
|
||||||
|
|
||||||
|
// Trigger unmute but don't actually change muted state
|
||||||
|
track.triggerEvent('unmute');
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove listeners on cleanup', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
const track = createMockTrack('audio', true);
|
||||||
|
const pc = createMockPeerConnection([track]);
|
||||||
|
|
||||||
|
const cleanup = addAudioTracksMuteStateListener(pc, callback);
|
||||||
|
cleanup?.();
|
||||||
|
|
||||||
|
// Change state and trigger - should not call callback
|
||||||
|
track.muted = false;
|
||||||
|
track.triggerEvent('unmute');
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user