feat: Allow 2-way audio detection to be skipped and timed (#2355)
- For #2313
This commit is contained in:
@@ -47,9 +47,10 @@ cameras:
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ---------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
|
| ---------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `disable` | | A list of camera capabilities to disable. By default all capabilities supported by the camera are enabled. |
|
| `disable` | | A list of camera capabilities to disable. By default all capabilities supported by the camera are enabled. |
|
||||||
| `disable_except` | | A list of camera capabilities to leave enabled if supported. Everything else will be disabled. |
|
| `disable_except` | | A list of camera capabilities to leave enabled if supported. Everything else will be disabled. |
|
||||||
|
| `force` | | A list of capabilities to force-enable instead of auto-detecting them. Currently only supports `2-way-audio`. `disable` / `disable_except` take precedence over `force`. |
|
||||||
|
|
||||||
### Capabilities
|
### Capabilities
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ cameras:
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `metadata_fetch_timeout_seconds` | `2` | Timeout for the go2rtc stream metadata fetch (this is used to detect stream capabilities such as `2-way-audio`). |
|
||||||
| `modes` | `[webrtc, mse, mp4, mjpeg]` | An ordered list of `go2rtc` modes to use. Valid values are `webrtc`, `mse`, `mp4` or `mjpeg` values. |
|
| `modes` | `[webrtc, mse, mp4, mjpeg]` | An ordered list of `go2rtc` modes to use. Valid values are `webrtc`, `mse`, `mp4` or `mjpeg` values. |
|
||||||
| `stream` | Determined by camera engine (e.g. `frigate` camera name). | A valid `go2rtc` stream name. |
|
| `stream` | Determined by camera engine (e.g. `frigate` camera name). | A valid `go2rtc` stream name. |
|
||||||
| `url` | Determined by camera engine (e.g. the `frigate` engine will automatically generate a URL for the go2rtc backend that runs in the Frigate container). | The root `go2rtc` URL the card should stream the video from. This is only needed for non-Frigate usecases, or advanced Frigate usecases. Example: `http://my-custom-go2rtc:1984` |
|
| `url` | Determined by camera engine (e.g. the `frigate` engine will automatically generate a URL for the go2rtc backend that runs in the Frigate container). | The root `go2rtc` URL the card should stream the video from. This is only needed for non-Frigate usecases, or advanced Frigate usecases. Example: `http://my-custom-go2rtc:1984` |
|
||||||
@@ -35,6 +36,13 @@ cameras:
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> If `url` is manually set and `proxy.live` is set to `auto` on the camera (the default), the video stream will automatically be proxied via the Home Assistant process if the [hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) is detected. See [proxying](./README.md?id=proxy).
|
> If `url` is manually set and `proxy.live` is set to `auto` on the camera (the default), the video stream will automatically be proxied via the Home Assistant process if the [hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) is detected. See [proxying](./README.md?id=proxy).
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> If you are certain your camera hardware supports 2-way audio but the
|
||||||
|
> microphone button is intermittently missing on load, try increasing
|
||||||
|
> `metadata_fetch_timeout_seconds` or use
|
||||||
|
> [`capabilities.force`](./README.md?id=capabilities) to skip metadata
|
||||||
|
> detection entirely.
|
||||||
|
|
||||||
## `image`
|
## `image`
|
||||||
|
|
||||||
All configuration is under:
|
All configuration is under:
|
||||||
@@ -119,6 +127,7 @@ cameras:
|
|||||||
- mjpeg
|
- mjpeg
|
||||||
stream: sitting_room
|
stream: sitting_room
|
||||||
url: 'https://my.custom.go2rtc.backend'
|
url: 'https://my.custom.go2rtc.backend'
|
||||||
|
metadata_fetch_timeout_seconds: 2
|
||||||
- camera_entity: camera.office_jsmpeg
|
- camera_entity: camera.office_jsmpeg
|
||||||
live_provider: jsmpeg
|
live_provider: jsmpeg
|
||||||
jsmpeg:
|
jsmpeg:
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ challenging.
|
|||||||
- Only the `webrtc` mode supports 2-way audio:
|
- Only the `webrtc` mode supports 2-way audio:
|
||||||
- Must have microphone menu button enabled:
|
- Must have microphone menu button enabled:
|
||||||
|
|
||||||
|
If your setup supports 2-way audio but detection is intermittent on load:
|
||||||
|
|
||||||
|
- Increase `cameras[].go2rtc.metadata_fetch_timeout_seconds`.
|
||||||
|
- Or force the capability with `cameras[].capabilities.force: ['2-way-audio']`.
|
||||||
|
|
||||||
## Example configuration
|
## Example configuration
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -30,6 +35,8 @@ cameras:
|
|||||||
go2rtc:
|
go2rtc:
|
||||||
modes:
|
modes:
|
||||||
- webrtc
|
- webrtc
|
||||||
|
# Optional: For slower cameras increase timeout (default: 2)
|
||||||
|
metadata_fetch_timeout_seconds: 10
|
||||||
menu:
|
menu:
|
||||||
buttons:
|
buttons:
|
||||||
microphone:
|
microphone:
|
||||||
|
|||||||
@@ -85,15 +85,13 @@ export class Camera {
|
|||||||
): Promise<Capabilities> {
|
): Promise<Capabilities> {
|
||||||
const rawCapabilities = await this._getRawCapabilities(options);
|
const rawCapabilities = await this._getRawCapabilities(options);
|
||||||
const config = this.getConfig();
|
const config = this.getConfig();
|
||||||
const has2WayAudio = await liveProviderSupports2WayAudio(
|
const has2WayAudio = await this._has2WayAudioCapability(options.hass);
|
||||||
options.hass,
|
|
||||||
config,
|
|
||||||
this._getGo2RTCMetadataEndpoint(),
|
|
||||||
this.getProxyConfig(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Capabilities(
|
return new Capabilities(
|
||||||
{ ...rawCapabilities, '2-way-audio': has2WayAudio },
|
{
|
||||||
|
...rawCapabilities,
|
||||||
|
'2-way-audio': has2WayAudio,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
disable: config.capabilities?.disable,
|
disable: config.capabilities?.disable,
|
||||||
disableExcept: config.capabilities?.disable_except,
|
disableExcept: config.capabilities?.disable_except,
|
||||||
@@ -101,6 +99,26 @@ export class Camera {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async _has2WayAudioCapability(hass: HomeAssistant): Promise<boolean> {
|
||||||
|
if (this._config.capabilities?.disable?.includes('2-way-audio')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const disableExcept = this._config.capabilities?.disable_except;
|
||||||
|
if (disableExcept?.length && !disableExcept.includes('2-way-audio')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this._config.capabilities?.force?.includes('2-way-audio')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return await liveProviderSupports2WayAudio(
|
||||||
|
hass,
|
||||||
|
this.getConfig(),
|
||||||
|
this.getConfig().go2rtc.metadata_fetch_timeout_seconds,
|
||||||
|
this._getGo2RTCMetadataEndpoint(),
|
||||||
|
this.getProxyConfig(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get raw capabilities for this camera. Subclasses should override
|
* Get raw capabilities for this camera. Subclasses should override
|
||||||
* and call super._getRawCapabilities() to extend defaults.
|
* and call super._getRawCapabilities() to extend defaults.
|
||||||
|
|||||||
@@ -6,18 +6,14 @@ import { errorToConsole } from '../../../utils/basic';
|
|||||||
import { CameraProxyConfig } from '../../types';
|
import { CameraProxyConfig } from '../../types';
|
||||||
import { Go2RTCStreamInfo, go2RTCStreamInfoSchema } from './types';
|
import { Go2RTCStreamInfo, go2RTCStreamInfoSchema } from './types';
|
||||||
|
|
||||||
// Allow generous amount of time to fetch metadata (timeout matches that used in
|
|
||||||
// the Frigate frontend for the same call).
|
|
||||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2313
|
|
||||||
const GO2RTC_METADATA_TIMEOUT_SECONDS = 10;
|
|
||||||
|
|
||||||
const getGo2RTCStreamMetadata = async (
|
const getGo2RTCStreamMetadata = async (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
|
timeoutSeconds: number,
|
||||||
): Promise<Go2RTCStreamInfo | null> => {
|
): Promise<Go2RTCStreamInfo | null> => {
|
||||||
try {
|
try {
|
||||||
return await homeAssistantSignAndFetch(hass, endpoint, go2RTCStreamInfoSchema, {
|
return await homeAssistantSignAndFetch(hass, endpoint, go2RTCStreamInfoSchema, {
|
||||||
timeoutSeconds: GO2RTC_METADATA_TIMEOUT_SECONDS,
|
timeoutSeconds,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToConsole(e as Error);
|
errorToConsole(e as Error);
|
||||||
@@ -53,6 +49,7 @@ const streamSupports2WayAudio = (streamInfo: Go2RTCStreamInfo | null): boolean =
|
|||||||
*/
|
*/
|
||||||
export const supports2WayAudio = async (
|
export const supports2WayAudio = async (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
metadataFetchTimeoutSeconds: number,
|
||||||
go2rtcMetadataEndpoint?: Endpoint | null,
|
go2rtcMetadataEndpoint?: Endpoint | null,
|
||||||
proxyConfig?: CameraProxyConfig,
|
proxyConfig?: CameraProxyConfig,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
@@ -67,6 +64,10 @@ export const supports2WayAudio = async (
|
|||||||
{ context: 'live', openLimit: 1 },
|
{ context: 'live', openLimit: 1 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const streamInfo = await getGo2RTCStreamMetadata(hass, endpoint);
|
const streamInfo = await getGo2RTCStreamMetadata(
|
||||||
|
hass,
|
||||||
|
endpoint,
|
||||||
|
metadataFetchTimeoutSeconds,
|
||||||
|
);
|
||||||
return streamSupports2WayAudio(streamInfo);
|
return streamSupports2WayAudio(streamInfo);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ const LIVE_PROVIDERS = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type LiveProvider = (typeof LIVE_PROVIDERS)[number];
|
export type LiveProvider = (typeof LIVE_PROVIDERS)[number];
|
||||||
|
|
||||||
|
const go2rtcConfigDefault = {
|
||||||
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2313
|
||||||
|
metadata_fetch_timeout_seconds: 2,
|
||||||
|
};
|
||||||
|
|
||||||
const go2rtcConfigSchema = z.object({
|
const go2rtcConfigSchema = z.object({
|
||||||
url: z
|
url: z
|
||||||
.string()
|
.string()
|
||||||
@@ -39,6 +44,11 @@ const go2rtcConfigSchema = z.object({
|
|||||||
host: z.string().optional(),
|
host: z.string().optional(),
|
||||||
modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(),
|
modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(),
|
||||||
stream: z.string().optional(),
|
stream: z.string().optional(),
|
||||||
|
metadata_fetch_timeout_seconds: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(go2rtcConfigDefault.metadata_fetch_timeout_seconds),
|
||||||
});
|
});
|
||||||
|
|
||||||
const webrtcCardConfigSchema = z
|
const webrtcCardConfigSchema = z
|
||||||
@@ -219,6 +229,7 @@ export const cameraConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
disable: z.enum(capabilityKeys).array().optional(),
|
disable: z.enum(capabilityKeys).array().optional(),
|
||||||
disable_except: z.enum(capabilityKeys).array().optional(),
|
disable_except: z.enum(capabilityKeys).array().optional(),
|
||||||
|
force: z.enum(['2-way-audio']).array().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
@@ -306,7 +317,7 @@ export const cameraConfigSchema = z
|
|||||||
|
|
||||||
// Live provider options.
|
// Live provider options.
|
||||||
live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider),
|
live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider),
|
||||||
go2rtc: go2rtcConfigSchema.optional(),
|
go2rtc: go2rtcConfigSchema.optional().default(go2rtcConfigDefault),
|
||||||
image: imageBaseConfigSchema.optional().default(imageConfigDefault),
|
image: imageBaseConfigSchema.optional().default(imageConfigDefault),
|
||||||
jsmpeg: jsmpegConfigSchema.optional(),
|
jsmpeg: jsmpegConfigSchema.optional(),
|
||||||
webrtc_card: webrtcCardConfigSchema.optional(),
|
webrtc_card: webrtcCardConfigSchema.optional(),
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export const CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE =
|
|||||||
`${CONF_CAMERAS}.#.capabilities.disable` as const;
|
`${CONF_CAMERAS}.#.capabilities.disable` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT =
|
export const CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT =
|
||||||
`${CONF_CAMERAS}.#.capabilities.disable_except` as const;
|
`${CONF_CAMERAS}.#.capabilities.disable_except` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_CAPABILITIES_FORCE =
|
||||||
|
`${CONF_CAMERAS}.#.capabilities.force` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_CAST_METHOD = `${CONF_CAMERAS}.#.cast.method` as const;
|
export const CONF_CAMERAS_ARRAY_CAST_METHOD = `${CONF_CAMERAS}.#.cast.method` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH =
|
export const CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH =
|
||||||
`${CONF_CAMERAS}.#.cast.dashboard.dashboard_path` as const;
|
`${CONF_CAMERAS}.#.cast.dashboard.dashboard_path` as const;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
|
CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
|
||||||
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE,
|
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE,
|
||||||
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT,
|
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT,
|
||||||
|
CONF_CAMERAS_ARRAY_CAPABILITIES_FORCE,
|
||||||
CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH,
|
CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH,
|
||||||
CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH,
|
CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH,
|
||||||
CONF_CAMERAS_ARRAY_CAST_METHOD,
|
CONF_CAMERAS_ARRAY_CAST_METHOD,
|
||||||
@@ -946,6 +947,14 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected _forceableCapabilities: EditorSelectOption[] = [
|
||||||
|
{ value: '', label: '' },
|
||||||
|
{
|
||||||
|
value: '2-way-audio',
|
||||||
|
label: localize('config.cameras.capabilities.capabilities.2-way-audio'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
protected _defaultResetInteractionModes: EditorSelectOption[] = [
|
protected _defaultResetInteractionModes: EditorSelectOption[] = [
|
||||||
{ value: '', label: '' },
|
{ value: '', label: '' },
|
||||||
{
|
{
|
||||||
@@ -2703,6 +2712,16 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
multiple: true,
|
multiple: true,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
|
${this._renderOptionSelector(
|
||||||
|
getArrayConfigPath(
|
||||||
|
CONF_CAMERAS_ARRAY_CAPABILITIES_FORCE,
|
||||||
|
cameraIndex,
|
||||||
|
),
|
||||||
|
this._forceableCapabilities,
|
||||||
|
{
|
||||||
|
multiple: true,
|
||||||
|
},
|
||||||
|
)}
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
${this._putInSubmenu(
|
${this._putInSubmenu(
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"camera_entity": "Camera Entity",
|
"camera_entity": "Camera Entity",
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
|
"2-way-audio": "2-way Audio",
|
||||||
"clips": "Clips",
|
"clips": "Clips",
|
||||||
"favorite-events": "Favorite Events",
|
"favorite-events": "Favorite Events",
|
||||||
"favorite-recordings": "Favorite Recordings",
|
"favorite-recordings": "Favorite Recordings",
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
},
|
},
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
"disable_except": "Disable except",
|
"disable_except": "Disable except",
|
||||||
|
"force": "Force",
|
||||||
"editor_label": "Camera capabilities"
|
"editor_label": "Camera capabilities"
|
||||||
},
|
},
|
||||||
"cast": {
|
"cast": {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const getResolvedLiveProvider = (
|
|||||||
export const liveProviderSupports2WayAudio = async (
|
export const liveProviderSupports2WayAudio = async (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config: CameraConfig,
|
config: CameraConfig,
|
||||||
|
metadataFetchTimeoutSeconds: number,
|
||||||
go2rtcMetadataEndpoint?: Endpoint | null,
|
go2rtcMetadataEndpoint?: Endpoint | null,
|
||||||
proxyConfig?: CameraProxyConfig,
|
proxyConfig?: CameraProxyConfig,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
@@ -32,5 +33,10 @@ export const liveProviderSupports2WayAudio = async (
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return gortcSupports2WayAudio(hass, go2rtcMetadataEndpoint, proxyConfig);
|
return gortcSupports2WayAudio(
|
||||||
|
hass,
|
||||||
|
metadataFetchTimeoutSeconds,
|
||||||
|
go2rtcMetadataEndpoint,
|
||||||
|
proxyConfig,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ describe('Camera', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('initialize', () => {
|
describe('initialize', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('should initialize and destroy', async () => {
|
it('should initialize and destroy', async () => {
|
||||||
const camera = new Camera(
|
const camera = new Camera(
|
||||||
createCameraConfig({
|
createCameraConfig({
|
||||||
@@ -123,6 +127,7 @@ describe('Camera', () => {
|
|||||||
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
|
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
2,
|
||||||
{
|
{
|
||||||
endpoint:
|
endpoint:
|
||||||
'http://go2rtc/api/streams?src=stream&video=all&audio=allµphone',
|
'http://go2rtc/api/streams?src=stream&video=all&audio=allµphone',
|
||||||
@@ -154,6 +159,171 @@ describe('Camera', () => {
|
|||||||
|
|
||||||
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false);
|
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should pass camera go2rtc metadata timeout', async () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
go2rtc: {
|
||||||
|
url: 'http://go2rtc',
|
||||||
|
stream: 'stream',
|
||||||
|
metadata_fetch_timeout_seconds: 20,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
20,
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should force 2-way-audio capability true without metadata fetch', async () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
capabilities: {
|
||||||
|
force: ['2-way-audio'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled();
|
||||||
|
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer disable over force rules', async () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
capabilities: {
|
||||||
|
disable: ['2-way-audio'],
|
||||||
|
force: ['2-way-audio'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled();
|
||||||
|
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer disable_except over force rules', async () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
capabilities: {
|
||||||
|
disable_except: ['substream'],
|
||||||
|
force: ['2-way-audio'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled();
|
||||||
|
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not fetch metadata when 2-way-audio is disabled', async () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
capabilities: {
|
||||||
|
disable: ['2-way-audio'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled();
|
||||||
|
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not fetch metadata when disable_except excludes 2-way-audio', async () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
capabilities: {
|
||||||
|
disable_except: ['substream'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(liveProviderSupports2WayAudio).not.toHaveBeenCalled();
|
||||||
|
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch metadata when disable_except includes 2-way-audio', async () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
capabilities: {
|
||||||
|
disable_except: ['substream', '2-way-audio'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(liveProviderSupports2WayAudio).toHaveBeenCalled();
|
||||||
|
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch metadata when disable_except is empty', async () => {
|
||||||
|
const camera = new Camera(
|
||||||
|
createCameraConfig({
|
||||||
|
capabilities: {
|
||||||
|
disable_except: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
|
||||||
|
);
|
||||||
|
vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await camera.initialize({
|
||||||
|
hass: createHASS(),
|
||||||
|
stateWatcher: mock<StateWatcherSubscriptionInterface>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(liveProviderSupports2WayAudio).toHaveBeenCalled();
|
||||||
|
expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should handle trigger state changes', () => {
|
describe('should handle trigger state changes', () => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe('supports2WayAudio', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return false if no endpoint provided', async () => {
|
it('should return false if no endpoint provided', async () => {
|
||||||
expect(await supports2WayAudio(hass, null)).toBe(false);
|
expect(await supports2WayAudio(hass, 2, null)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false if fetch fails', async () => {
|
it('should return false if fetch fails', async () => {
|
||||||
@@ -26,7 +26,7 @@ describe('supports2WayAudio', () => {
|
|||||||
vi.mocked(homeAssistantSignAndFetch).mockRejectedValue(new Error('fetch error'));
|
vi.mocked(homeAssistantSignAndFetch).mockRejectedValue(new Error('fetch error'));
|
||||||
|
|
||||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
const result = await supports2WayAudio(hass, endpoint);
|
const result = await supports2WayAudio(hass, 2, endpoint);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
expect(spy).toHaveBeenCalledWith('fetch error');
|
expect(spy).toHaveBeenCalledWith('fetch error');
|
||||||
@@ -37,7 +37,35 @@ describe('supports2WayAudio', () => {
|
|||||||
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
|
||||||
vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ producers: undefined });
|
vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ producers: undefined });
|
||||||
|
|
||||||
expect(await supports2WayAudio(hass, endpoint)).toBe(false);
|
expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default metadata fetch timeout', async () => {
|
||||||
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
|
||||||
|
vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ producers: [] });
|
||||||
|
|
||||||
|
await supports2WayAudio(hass, 2, endpoint);
|
||||||
|
|
||||||
|
expect(homeAssistantSignAndFetch).toHaveBeenCalledWith(
|
||||||
|
hass,
|
||||||
|
endpoint,
|
||||||
|
expect.anything(),
|
||||||
|
{ timeoutSeconds: 2 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom metadata fetch timeout when provided', async () => {
|
||||||
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
|
||||||
|
vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ producers: [] });
|
||||||
|
|
||||||
|
await supports2WayAudio(hass, 15, endpoint);
|
||||||
|
|
||||||
|
expect(homeAssistantSignAndFetch).toHaveBeenCalledWith(
|
||||||
|
hass,
|
||||||
|
endpoint,
|
||||||
|
expect.anything(),
|
||||||
|
{ timeoutSeconds: 15 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false if no producer supports audio', async () => {
|
it('should return false if no producer supports audio', async () => {
|
||||||
@@ -50,7 +78,7 @@ describe('supports2WayAudio', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await supports2WayAudio(hass, endpoint)).toBe(false);
|
expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true if producer supports audio and sendonly', async () => {
|
it('should return true if producer supports audio and sendonly', async () => {
|
||||||
@@ -63,7 +91,7 @@ describe('supports2WayAudio', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await supports2WayAudio(hass, endpoint)).toBe(true);
|
expect(await supports2WayAudio(hass, 2, endpoint)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true if producer supports audio and sendrecv', async () => {
|
it('should return true if producer supports audio and sendrecv', async () => {
|
||||||
@@ -76,7 +104,7 @@ describe('supports2WayAudio', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await supports2WayAudio(hass, endpoint)).toBe(true);
|
expect(await supports2WayAudio(hass, 2, endpoint)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle missing medias in producer', async () => {
|
it('should handle missing medias in producer', async () => {
|
||||||
@@ -89,6 +117,6 @@ describe('supports2WayAudio', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await supports2WayAudio(hass, endpoint)).toBe(false);
|
expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ describe('config defaults', () => {
|
|||||||
frigate: {
|
frigate: {
|
||||||
client_id: 'frigate',
|
client_id: 'frigate',
|
||||||
},
|
},
|
||||||
|
go2rtc: {
|
||||||
|
metadata_fetch_timeout_seconds: 2,
|
||||||
|
},
|
||||||
image: {
|
image: {
|
||||||
mode: 'auto',
|
mode: 'auto',
|
||||||
refresh_seconds: 1,
|
refresh_seconds: 1,
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ describe('live-provider utils', () => {
|
|||||||
live_provider: 'ha',
|
live_provider: 'ha',
|
||||||
});
|
});
|
||||||
const hass = createHASS();
|
const hass = createHASS();
|
||||||
const result = await liveProviderSupports2WayAudio(hass, config);
|
const result = await liveProviderSupports2WayAudio(
|
||||||
|
hass,
|
||||||
|
config,
|
||||||
|
config.go2rtc.metadata_fetch_timeout_seconds,
|
||||||
|
);
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,10 +86,32 @@ describe('live-provider utils', () => {
|
|||||||
const hass = createHASS();
|
const hass = createHASS();
|
||||||
vi.mocked(go2rtcAudio.supports2WayAudio).mockResolvedValue(true);
|
vi.mocked(go2rtcAudio.supports2WayAudio).mockResolvedValue(true);
|
||||||
|
|
||||||
const result = await liveProviderSupports2WayAudio(hass, config);
|
const result = await liveProviderSupports2WayAudio(
|
||||||
|
hass,
|
||||||
|
config,
|
||||||
|
config.go2rtc.metadata_fetch_timeout_seconds,
|
||||||
|
);
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(go2rtcAudio.supports2WayAudio).toHaveBeenCalledWith(
|
expect(go2rtcAudio.supports2WayAudio).toHaveBeenCalledWith(
|
||||||
hass,
|
hass,
|
||||||
|
2,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass metadata fetch timeout through to go2rtc detection', async () => {
|
||||||
|
const config = createCameraConfig({
|
||||||
|
live_provider: 'go2rtc',
|
||||||
|
});
|
||||||
|
const hass = createHASS();
|
||||||
|
vi.mocked(go2rtcAudio.supports2WayAudio).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await liveProviderSupports2WayAudio(hass, config, 30);
|
||||||
|
|
||||||
|
expect(go2rtcAudio.supports2WayAudio).toHaveBeenCalledWith(
|
||||||
|
hass,
|
||||||
|
30,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user