Files
Filip Pytlounanddermotduffy 3ee6b6059e feat: Add microphone audio processing constraints (#2708)
## Summary

- add optional microphone constraints for echo cancellation, noise
suppression, automatic gain control, and channel count
- request configured values as non-mandatory `ideal` constraints
- expose privacy-safe microphone capabilities, requested constraints,
and applied settings in card diagnostics
- document the new configuration and add schema, microphone manager, and
diagnostics tests

## Motivation

The card currently calls `getUserMedia()` with `audio: true`. This
leaves echo cancellation, noise suppression, automatic gain control, and
channel count implicit.

Browser and device behavior differs. Explicit processing defaults can
regress microphone gain or amplify noise on some devices. This change
therefore keeps all processing constraints optional and configurable.

## Configuration

```yaml
live:
  microphone:
    constraints:
      echo_cancellation: true
      noise_suppression: true
      auto_gain_control: false
      channel_count: 1
```

Configured values use `ideal` constraints. A browser can ignore
unsupported values. Card diagnostics show the browser capabilities, the
requested constraints, and the reported applied settings.

## Backward compatibility

- existing configurations still use `audio: true`
- no audio-processing defaults are added
- explicit `false` values are preserved
- diagnostic output excludes device and group identifiers

## Validation

- focused microphone, schema, and diagnostics tests: 46 passed
- full test suite: 7,177 passed
- lint passed
- format check passed
- typecheck passed
- unused-code check passed
- production build passed

The optional constraints were also tested successfully with an iOS Home
Assistant Companion client and a go2rtc-based full-duplex intercom. This
is a client microphone-processing change only. It does not add backend
audio denoise.

---------

Co-authored-by: dermotduffy <dermot.duffy@gmail.com>
2026-08-24 07:38:33 -07:00

1975 lines
51 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { advancedCameraCardCustomActionsBaseSchema } from '../../src/config/schema/actions/custom/base';
import {
actionConfigSchema,
statusBarActionConfigSchema,
} from '../../src/config/schema/actions/types';
import { automationsSchema } from '../../src/config/schema/automations';
import { cameraConfigSchema } from '../../src/config/schema/cameras';
import { conditionSchema } from '../../src/config/schema/condition-trigger/conditions/types';
import { dimensionsConfigSchema } from '../../src/config/schema/dimensions';
import { customSchema } from '../../src/config/schema/elements/stock/custom';
import { conditionalSchema } from '../../src/config/schema/elements/types';
import { createConfig } from './test-utils';
describe('config defaults', () => {
it('should be as expected', () => {
expect(createConfig()).toEqual({
cameras: [{}],
cameras_global: {
always_error_if_entity_unavailable: false,
dependencies: {
all_cameras: false,
cameras: [],
},
engine: 'auto',
frigate: {},
go2rtc: {
metadata_fetch_timeout_seconds: 2,
},
image: {
mode: 'auto',
refresh_seconds: 'auto',
},
live_provider: 'auto',
motioneye: {
images: {
directory_pattern: '%Y-%m-%d',
file_pattern: '%H-%M-%S',
},
movies: {
directory_pattern: '%Y-%m-%d',
file_pattern: '%H-%M-%S',
},
},
proxy: {
dynamic: true,
live: 'auto',
media: 'auto',
ssl_ciphers: 'auto',
ssl_verification: 'auto',
},
ptz: {
c2r_delay_between_calls_seconds: 0.2,
r2c_delay_between_calls_seconds: 0.5,
},
reolink: {
media_resolution: 'low',
},
triggers: {
doorbell: false,
entities: [],
events: [],
media_events: [],
motion: false,
occupancy: false,
reviews: {
description: true,
severities: ['high'],
},
},
},
debug: {
logging: false,
},
dimensions: {
aspect_ratio: [16, 9],
aspect_ratio_mode: 'dynamic',
height: 'auto',
},
elements: [],
image: {
mode: 'auto',
proxy: {
dynamic: true,
ssl_ciphers: 'auto',
ssl_verification: 'auto',
enabled: false,
},
refresh_seconds: 'auto',
zoomable: true,
},
live: {
auto_mute: ['unselected', 'hidden', 'microphone', 'call'],
auto_pause: [],
auto_play: ['selected', 'visible'],
auto_unmute: ['microphone', 'call'],
controls: {
builtin: true,
call: {
button_size: 40,
enabled: true,
lock: true,
ringtone: { type: 'chime', repeat: 0 },
unanswered_timeout_seconds: 60,
},
next_previous: {
auto_hide: ['call', 'casting'],
size: 48,
style: 'chevrons',
},
ptz: {
type: 'buttons',
hide_home: false,
hide_pan_tilt: false,
hide_type: false,
hide_zoom: false,
mode: 'auto',
orientation: 'horizontal',
position: 'bottom-right',
},
thumbnails: {
mode: 'right',
show_details: true,
show_download_control: false,
show_favorite_control: true,
show_info_control: true,
show_review_control: true,
show_timeline_control: false,
size: 100,
},
timeline: {
clustering_threshold: 3,
format: {
'24h': true,
},
mode: 'none',
pan_mode: 'pan',
show_recordings: true,
style: 'ribbon',
window_seconds: 3600,
},
wheel: true,
},
draggable: true,
lazy_load: true,
lazy_unload: [],
microphone: {
always_connected: false,
audio_processing: {
auto_gain_control: 'auto',
echo_cancellation: 'auto',
noise_suppression: 'auto',
},
auto_mute: [],
auto_unmute: [],
mute_after_microphone_mute_seconds: 60,
},
preload: false,
show_image_during_load: true,
transition_effect: 'slide',
zoomable: true,
},
media_gallery: {
controls: {
filter: {
mode: 'right',
},
thumbnails: {
show_details: false,
show_download_control: false,
show_favorite_control: true,
show_info_control: true,
show_review_control: true,
show_timeline_control: false,
size: 100,
},
},
},
media_viewer: {
auto_mute: ['unselected', 'hidden'],
auto_pause: ['unselected', 'hidden'],
auto_play: ['selected', 'visible'],
auto_unmute: [],
controls: {
builtin: true,
next_previous: {
auto_hide: ['casting'],
size: 48,
style: 'thumbnails',
},
ptz: {
type: 'buttons',
hide_home: false,
hide_pan_tilt: false,
hide_type: false,
hide_zoom: false,
mode: 'off',
orientation: 'horizontal',
position: 'bottom-right',
},
thumbnails: {
mode: 'right',
show_details: true,
show_download_control: false,
show_favorite_control: true,
show_info_control: true,
show_review_control: true,
show_timeline_control: false,
size: 100,
},
timeline: {
clustering_threshold: 3,
format: {
'24h': true,
},
mode: 'none',
pan_mode: 'pan',
show_recordings: true,
style: 'ribbon',
window_seconds: 3600,
},
wheel: true,
},
draggable: true,
lazy_load: true,
snapshot_click_plays_clip: true,
transition_effect: 'slide',
zoomable: true,
},
menu: {
alignment: 'left',
auto_hide: ['call', 'casting'],
button_size: 40,
buttons: {
call: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
camera_ui: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
cameras: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
clips: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
display_mode: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
download: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
expand: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
folders: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
fullscreen: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
gallery: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
image: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
info: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
iris: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
live: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
media_player: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
microphone: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
type: 'momentary',
},
mute: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
pip: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
play: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
ptz_controls: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
ptz_home: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
recordings: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
reviews: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
screenshot: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
set_review: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
snapshots: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
substreams: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
timeline: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
},
position: 'top',
style: 'hidden',
},
performance: {
features: {
animated_progress_indicator: true,
card_loading_effects: true,
card_loading_indicator: true,
media_chunk_size: 50,
},
style: {
border_radius: true,
box_shadow: true,
},
},
remote_control: {
entities: {
camera_priority: 'card',
},
},
status_bar: {
auto_hide: ['call', 'casting'],
height: 40,
items: {
engine: {
enabled: true,
permanent: false,
priority: 50,
},
issues: {
enabled: true,
permanent: true,
priority: 50,
},
resolution: {
enabled: true,
permanent: false,
priority: 50,
},
severity: {
enabled: true,
permanent: false,
priority: 50,
},
technology: {
enabled: true,
permanent: false,
priority: 50,
},
title: {
enabled: true,
permanent: false,
priority: 50,
},
},
popup_seconds: 3,
position: 'bottom',
style: 'popup',
},
timeline: {
clustering_threshold: 3,
controls: {
thumbnails: {
mode: 'right',
show_details: true,
show_download_control: false,
show_favorite_control: true,
show_info_control: true,
show_review_control: true,
show_timeline_control: false,
size: 100,
},
},
format: {
'24h': true,
},
show_recordings: true,
style: 'stack',
window_seconds: 3600,
},
type: 'advanced-camera-card',
view: {
camera_select: 'current',
default: 'auto',
default_cycle_camera: false,
default_reset: {
after_interaction: false,
entities: [],
every_seconds: 0,
interaction_mode: 'inactive',
},
dim: false,
interaction_seconds: 300,
keyboard_shortcuts: {
enabled: true,
ptz_down: {
alt: false,
ctrl: false,
key: 'ArrowDown',
meta: false,
},
ptz_home: {
alt: false,
ctrl: false,
key: 'h',
meta: false,
},
ptz_left: {
alt: false,
ctrl: false,
key: 'ArrowLeft',
meta: false,
},
ptz_right: {
alt: false,
ctrl: false,
key: 'ArrowRight',
meta: false,
},
ptz_up: {
alt: false,
ctrl: false,
key: 'ArrowUp',
meta: false,
},
ptz_zoom_in: {
alt: false,
ctrl: false,
key: '+',
meta: false,
},
ptz_zoom_out: {
alt: false,
ctrl: false,
key: '-',
meta: false,
},
},
theme: {
themes: ['traditional'],
},
triggers: {
actions: {
interaction_mode: 'inactive',
trigger: 'update',
untrigger: 'none',
},
filter_selected_camera: true,
show_trigger_status: false,
event_hold_seconds: 30,
untrigger_delay_seconds: 0,
untrigger_force_seconds: 0,
},
issues: {
interaction_mode: 'all',
retry_seconds: 'auto',
},
},
});
});
it('should include all stock elements', () => {
const stockElements = [
{
type: 'icon',
icon: 'mdi:dog',
entity: 'camera.office',
},
{
type: 'custom:element',
data: 'foo',
},
{
type: 'image',
entity: 'camera.office',
image: 'image',
camera_image: 'camera_image',
camera_view: 'camera_view',
state_image: {},
filter: 'filter',
state_filter: { on: '/foo' },
aspect_ratio: '16 / 9',
},
{
type: 'custom:advanced-camera-card-menu-icon',
alignment: 'matching',
enabled: true,
entity: 'camera.kitchen',
icon: 'mdi:cat',
permanent: false,
priority: 50,
state_color: true,
},
{
type: 'custom:advanced-camera-card-menu-state-icon',
alignment: 'matching',
enabled: true,
entity: 'camera.kitchen',
icon: 'mdi:sheep',
permanent: false,
priority: 50,
state_color: false,
},
{
type: 'state-badge',
entity: 'sensor.kitchen_dining_multisensor_air_temperature',
style: {
left: '100px',
top: '50px',
},
title: 'Temperature',
},
{
type: 'state-icon',
entity: 'light.office_main_lights',
icon: 'mdi:lamp',
state_color: true,
style: {
left: '100px',
top: '100px',
},
},
{
type: 'state-label',
entity: 'sensor.kitchen_motion_sensor_battery',
attribute: 'battery_voltage',
prefix: 'Volts',
title: 'Battery Voltage',
style: {
left: '100px',
top: '150px',
},
},
{
type: 'state-label',
entity: 'sensor.kitchen_motion_sensor_battery',
attribute: 'battery_voltage',
prefix: 'Volts: ',
title: 'Battery Voltage',
style: {
backgroundColor: 'black',
left: '100px',
top: '200px',
},
},
{
type: 'service-button',
title: 'Light on',
service: 'homeassistant.turn_on',
service_data: {
entity: 'light.office_main_lights',
},
style: {
left: '100px',
top: '250px',
},
},
{
type: 'icon',
icon: 'mdi:cow',
title: 'Moo',
style: {
left: '100px',
top: '300px',
},
},
{
type: 'image',
entity: 'light.office_main_lights',
title: 'Image',
state_image: {
on: 'https://picsum.photos/id/1003/1181/1772',
off: 'https://picsum.photos/id/102/4320/3240',
},
state_filter: {
on: 'brightness(110%) saturate(1.2)',
off: 'brightness(50%) hue-rotate(45deg)',
},
style: {
left: '100px',
top: '350px',
height: '50px',
width: '100px',
},
},
{
type: 'conditional',
conditions: [
{
condition: 'state',
entity_id: 'light.office_main_lights',
state: 'on',
state_not: 'off',
},
],
elements: [
{
type: 'icon',
icon: 'mdi:dog',
title: 'Woof',
style: {
left: '100px',
top: '400px',
},
},
],
},
];
expect(
createConfig({
elements: stockElements,
}).elements,
).toEqual(stockElements);
});
it('should include all custom elements', () => {
const customElements = [
{
type: 'custom:advanced-camera-card-menu-icon',
alignment: 'matching',
enabled: true,
entity: 'light.office_main_lights',
icon: 'mdi:car',
permanent: false,
priority: 50,
state_color: true,
style: {
color: 'white',
},
title: 'Vroom',
},
{
type: 'custom:advanced-camera-card-menu-state-icon',
alignment: 'matching',
enabled: true,
entity: 'light.office_main_lights',
icon: 'mdi:chair-rolling',
permanent: false,
priority: 50,
state_color: true,
style: {
color: 'white',
},
title: 'Office lights',
},
{
type: 'custom:advanced-camera-card-menu-submenu',
alignment: 'matching',
enabled: true,
entity: 'light.office_main_lights',
icon: 'mdi:menu',
items: [
{
enabled: true,
entity: 'light.office_main_lights',
icon: 'mdi:lightbulb',
selected: false,
state_color: true,
style: {
color: 'white',
},
tap_action: {
action: 'toggle',
},
title: 'Lights',
},
{
enabled: true,
icon: 'mdi:google',
selected: false,
state_color: false,
style: {
color: 'white',
},
tap_action: {
action: 'url',
url_path: 'https://www.google.com',
},
title: 'Google',
},
],
permanent: false,
priority: 50,
state_color: true,
style: {
color: 'white',
},
title: 'Office lights',
},
{
type: 'custom:advanced-camera-card-menu-submenu-select',
alignment: 'matching',
enabled: true,
entity: 'input_select.kitchen_scene',
icon: 'mdi:lamps',
options: {
'scene.kitchen_cooking_scene': {
enabled: true,
icon: 'mdi:chef-hat',
selected: false,
state_color: true,
style: {
color: 'white',
},
title: 'Cooking time!',
},
'scene.kitchen_tv_scene': {
enabled: true,
icon: 'mdi:television',
selected: false,
state_color: true,
title: 'TV!',
},
},
permanent: false,
priority: 50,
state_color: true,
style: {
color: 'white',
},
title: 'Kitchen Scene',
},
{
type: 'custom:advanced-camera-card-conditional',
elements: [
{
type: 'icon',
icon: 'mdi:pig',
title: 'Oink',
style: {
left: '300px',
top: '100px',
},
},
],
conditions: [
{
condition: 'view',
views: ['live'],
},
],
},
{
type: 'custom:advanced-camera-card-status-bar-string',
enabled: true,
exclusive: false,
expand: false,
permanent: false,
string: 'Intruder alert!',
priority: 50,
sufficient: false,
},
{
type: 'custom:advanced-camera-card-status-bar-icon',
enabled: true,
exclusive: false,
expand: false,
icon: 'mdi:cow',
permanent: false,
priority: 50,
sufficient: false,
},
{
type: 'custom:advanced-camera-card-status-bar-image',
enabled: true,
exclusive: false,
expand: false,
image: 'https://my.site.com/status.png',
permanent: false,
priority: 50,
sufficient: false,
},
];
expect(
createConfig({
elements: customElements,
}).elements,
).toEqual(customElements);
});
it('should include all conditions', () => {
const conditions = [
{ condition: 'and', conditions: [{ condition: 'initialized', ever: false }] },
{ condition: 'call', call: ['ringing', 'answered'] },
{ condition: 'camera', cameras: ['camera.office'] },
{ condition: 'display_mode', display_mode: 'single' },
{ condition: 'expand', expand: true },
{ condition: 'fullscreen', fullscreen: true },
{ condition: 'initialized', ever: false },
{ condition: 'interaction', interaction: true },
{
condition: 'key',
alt: false,
ctrl: false,
key: 'F',
meta: false,
shift: false,
state: 'down',
},
{ condition: 'media_loaded', media_loaded: true },
{ condition: 'microphone', muted: true },
{ condition: 'not', conditions: [{ condition: 'initialized', ever: false }] },
{
condition: 'numeric_state',
entity_id: 'sensor.office_temperature',
above: 10,
below: 20,
},
{ condition: 'or', conditions: [{ condition: 'initialized', ever: false }] },
{ condition: 'screen', media_query: '(orientation: landscape)' },
{
condition: 'state',
entity_id: 'climate.office',
state: 'heat',
state_not: 'off',
},
{ condition: 'triggered', triggered: ['camera.office'] },
{ condition: 'user', users: ['581fca7fdc014b8b894519cc531f9a04'] },
{
condition: 'user_agent',
user_agent:
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
user_agent_re: 'Chrome/',
companion: true,
},
{ condition: 'view', views: ['live'] },
];
const elements = [
{
type: 'custom:advanced-camera-card-conditional',
elements: [
{
type: 'icon',
icon: 'mdi:pig',
},
],
conditions: conditions,
},
];
expect(
createConfig({
elements: elements,
}).elements,
).toEqual(elements);
});
it('should include all stock actions', () => {
const stockActions = [
{
action: 'more-info',
},
{
action: 'toggle',
},
{
action: 'call-service',
service: 'homeassistant.toggle',
data: {
entity_id: 'light.office_main_lights',
},
},
{
action: 'navigate',
navigation_path: '/lovelace/2',
},
{
action: 'url',
url_path: 'https://www.home-assistant.io/',
},
{
action: 'none',
},
{
action: 'fire-dom-event',
key: 'value',
},
{
action: 'perform-action',
perform_action: 'homeassistant.toggle',
target: {
entity_id: 'light.office_main_lights',
},
},
];
expect(
createConfig({
live: {
actions: {
tap_action: stockActions,
},
},
}).live.actions?.tap_action,
).toEqual(stockActions);
});
it('should include all custom actions', () => {
const customActions = [
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'camera_select',
camera: 'camera.front_door',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'camera_ui',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'clip',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'clips',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'default',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'diagnostics',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'display_mode_select',
display_mode: 'grid',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'download',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'expand',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'fullscreen',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'image',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'live',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'substream_off',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'substream_on',
camera: 'camera.front_door',
stream: 'camera.front_door_hd',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'log',
message: 'Hello, world!',
level: 'debug',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'media_player',
media_player: 'media_player.nesthub50be',
media_player_action: 'play',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'media_player',
media_player: 'media_player.nesthub',
media_player_action: 'stop',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'menu_toggle',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_mute',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_unmute',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'mute',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'notification',
notification: {
heading: {
text: 'Attention',
icon: 'mdi:alert',
severity: 'high',
},
body: { text: 'Something happened.' },
metadata: [{ text: 'Detail 1', icon: 'mdi:info' }],
controls: [
{
icon: 'mdi:check',
tooltip: 'Acknowledge',
dismiss: true,
},
{
icon: 'mdi:eye',
dismiss: false,
},
],
},
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'pause',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'play',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'ptz',
ptz_action: 'preset',
ptz_preset: 'doorway',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'ptz_controls',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'ptz_digital',
absolute: {
zoom: 5,
pan: {
x: 58,
y: 14,
},
},
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'ptz_multi',
ptz_action: 'left',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'recording',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'recordings',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'screenshot',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'sleep',
duration: {
h: 1,
m: 20,
s: 56,
ms: 422,
},
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'snapshot',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'snapshots',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'timeline',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'unmute',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'status_bar',
status_bar_action: 'add',
items: [
{
type: 'custom:advanced-camera-card-status-bar-string',
enabled: true,
exclusive: false,
expand: false,
permanent: false,
string: 'Intruder alert!',
priority: 50,
sufficient: false,
},
{
type: 'custom:advanced-camera-card-status-bar-icon',
enabled: true,
exclusive: false,
expand: false,
icon: 'mdi:cow',
permanent: false,
priority: 50,
sufficient: false,
},
{
type: 'custom:advanced-camera-card-status-bar-image',
enabled: true,
exclusive: false,
expand: false,
image: 'https://my.site.com/status.png',
permanent: false,
priority: 50,
sufficient: false,
},
],
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'status_bar',
status_bar_action: 'remove',
items: [
{
type: 'custom:advanced-camera-card-status-bar-string',
enabled: true,
exclusive: false,
expand: false,
permanent: false,
string: 'Intruder alert!',
priority: 50,
sufficient: false,
},
{
type: 'custom:advanced-camera-card-status-bar-icon',
enabled: true,
exclusive: false,
expand: false,
icon: 'mdi:cow',
permanent: false,
priority: 50,
sufficient: false,
},
{
type: 'custom:advanced-camera-card-status-bar-image',
enabled: true,
exclusive: false,
expand: false,
image: 'https://my.site.com/status.png',
permanent: false,
priority: 50,
sufficient: false,
},
],
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'status_bar',
status_bar_action: 'reset',
},
];
expect(
createConfig({
live: {
actions: {
tap_action: customActions,
},
},
}).live.actions?.tap_action,
).toEqual(
// Action type will be transformed to fire-dom-event.
customActions.map((action) => ({ ...action, action: 'fire-dom-event' })),
);
});
});
it('should transform dimensions.aspect_ratio', () => {
expect(
dimensionsConfigSchema.parse({
aspect_ratio: '16 / 9',
}),
).toEqual(expect.objectContaining({ aspect_ratio: [16, 9] }));
expect(
dimensionsConfigSchema.parse({
aspect_ratio: '16 : 9',
}),
).toEqual(expect.objectContaining({ aspect_ratio: [16, 9] }));
expect(
dimensionsConfigSchema.parse({
aspect_ratio: [16, 9],
}),
).toEqual(expect.objectContaining({ aspect_ratio: [16, 9] }));
});
describe('should refine user_agent_re conditions', () => {
it('should successfully parse valid user_agent_re condition', () => {
expect(
conditionSchema.parse({
condition: 'user_agent',
user_agent_re: 'Chrome/',
}),
).toEqual({
condition: 'user_agent',
user_agent_re: 'Chrome/',
});
});
it('should reject invalid user_agent_re conditions', () => {
expect(() =>
conditionSchema.parse({
condition: 'user_agent',
user_agent_re: '[',
}),
).toThrow(/Invalid regular expression/);
});
});
describe('conditions should accept Home Assistant composite shorthand', () => {
it('should expand and/or/not operator shorthand', () => {
for (const op of ['and', 'or', 'not'] as const) {
expect(
conditionSchema.parse({
[op]: [{ condition: 'fullscreen', fullscreen: true }],
}),
).toMatchObject({
condition: op,
conditions: [{ condition: 'fullscreen', fullscreen: true }],
});
}
});
it('should expand a condition list to an implicit and', () => {
expect(
conditionSchema.parse({
condition: [
{ condition: 'fullscreen', fullscreen: true },
{ condition: 'expand', expand: true },
],
}),
).toMatchObject({
condition: 'and',
conditions: [
{ condition: 'fullscreen', fullscreen: true },
{ condition: 'expand', expand: true },
],
});
});
it('should normalize a single shorthand condition to a list', () => {
expect(
conditionSchema.parse({ or: { condition: 'fullscreen', fullscreen: true } }),
).toMatchObject({
condition: 'or',
conditions: [{ condition: 'fullscreen', fullscreen: true }],
});
});
it('should expand nested shorthand and preserve base fields', () => {
expect(
conditionSchema.parse({
or: [{ and: [{ condition: 'fullscreen', fullscreen: true }] }],
enabled: false,
}),
).toMatchObject({
condition: 'or',
enabled: false,
conditions: [
{
condition: 'and',
conditions: [{ condition: 'fullscreen', fullscreen: true }],
},
],
});
});
it('should leave a canonical condition untouched', () => {
expect(
conditionSchema.parse({ condition: 'fullscreen', fullscreen: true }),
).toMatchObject({ condition: 'fullscreen', fullscreen: true });
});
it('should reject ambiguous or non-record shorthand', () => {
expect(conditionSchema.safeParse({ and: [], or: [] }).success).toBe(false);
expect(conditionSchema.safeParse('nope').success).toBe(false);
});
});
it('should transform action', () => {
expect(
advancedCameraCardCustomActionsBaseSchema.parse({
action: 'custom:advanced-camera-card-action',
}),
).toEqual({
action: 'fire-dom-event',
});
});
describe('should convert webrtc card PTZ to Advanced Camera Card PTZ', () => {
describe('relative actions', () => {
it.each([
['left' as const],
['right' as const],
['up' as const],
['down' as const],
['zoom_in' as const],
['zoom_out' as const],
])('%s', (action: string) => {
expect(
cameraConfigSchema.parse({
ptz: {
service: 'foo',
[`data_${action}`]: {
device: '048123',
cmd: action,
},
},
}),
).toEqual(
expect.objectContaining({
ptz: expect.objectContaining({
[`actions_${action}`]: {
action: 'perform-action',
perform_action: 'foo',
data: {
device: '048123',
cmd: action,
},
},
}),
}),
);
});
});
describe('continuous actions', () => {
it.each([
['left' as const],
['right' as const],
['up' as const],
['down' as const],
['zoom_in' as const],
['zoom_out' as const],
])('%s', (action: string) => {
expect(
cameraConfigSchema.parse({
ptz: {
service: 'foo',
[`data_start_${action}`]: {
device: '048123',
cmd: action,
phase: 'start',
},
[`data_end_${action}`]: {
device: '048123',
cmd: action,
phase: 'stop',
},
},
}),
).toEqual(
expect.objectContaining({
ptz: expect.objectContaining({
[`actions_${action}_start`]: {
action: 'perform-action',
perform_action: 'foo',
data: {
device: '048123',
cmd: action,
phase: 'start',
},
},
[`actions_${action}_stop`]: {
action: 'perform-action',
perform_action: 'foo',
data: {
device: '048123',
cmd: action,
phase: 'stop',
},
},
}),
}),
);
});
});
it('should parse presets via presets sub-object', () => {
expect(
cameraConfigSchema.parse({
ptz: {
service: 'service_outer',
presets: {
service: 'service_inner',
data_home: {
device: '048123',
cmd: 'home',
},
data_another: {
device: '048123',
cmd: 'another',
},
},
},
}),
).toEqual(
expect.objectContaining({
ptz: expect.objectContaining({
presets: {
home: {
action: 'perform-action',
perform_action: 'service_inner',
data: {
device: '048123',
cmd: 'home',
},
},
another: {
action: 'perform-action',
perform_action: 'service_inner',
data: {
device: '048123',
cmd: 'another',
},
},
},
}),
}),
);
});
it('should prioritize actions_left over data_left', () => {
const result = cameraConfigSchema.parse({
ptz: {
service: 'foo',
data_left: { cmd: 'from_data' },
actions_left: {
action: 'perform-action',
perform_action: 'bar',
data: { cmd: 'from_actions' },
},
},
});
expect(result).toEqual(
expect.objectContaining({
ptz: expect.objectContaining({
actions_left: {
action: 'perform-action',
perform_action: 'bar',
data: { cmd: 'from_actions' },
},
}),
}),
);
});
it('should create a home preset from data_home', () => {
expect(
cameraConfigSchema.parse({
ptz: {
service: 'foo',
data_home: {
device: '048123',
cmd: 'home',
},
},
}),
).toEqual(
expect.objectContaining({
ptz: expect.objectContaining({
presets: {
home: {
action: 'perform-action',
perform_action: 'foo',
data: {
device: '048123',
cmd: 'home',
},
},
},
}),
}),
);
});
it('should not overwrite existing home preset from data_home', () => {
expect(
cameraConfigSchema.parse({
ptz: {
service: 'foo',
data_home: {
device: '048123',
cmd: 'home_data',
},
presets: {
home: {
action: 'perform-action',
perform_action: 'bar',
data: { cmd: 'home_preset' },
},
},
},
}),
).toEqual(
expect.objectContaining({
ptz: expect.objectContaining({
presets: {
home: {
action: 'perform-action',
perform_action: 'bar',
data: { cmd: 'home_preset' },
},
},
}),
}),
);
});
});
describe('should lazy evaluate schemas', () => {
it('should parse conditional picture element', () => {
expect(
conditionalSchema.parse({
type: 'conditional',
conditions: [
{
condition: 'state',
entity_id: 'light.office_main_lights',
state: 'on',
state_not: 'off',
},
],
elements: [
{
type: 'icon',
icon: 'mdi:dog',
title: 'Woof',
style: {
left: '100px',
top: '400px',
},
},
],
}),
).toEqual({
conditions: [
{
condition: 'state',
entity_id: 'light.office_main_lights',
state: 'on',
state_not: 'off',
},
],
elements: [
{
icon: 'mdi:dog',
style: {
left: '100px',
top: '400px',
},
title: 'Woof',
type: 'icon',
},
],
type: 'conditional',
});
});
it('should parse status bar actions', () => {
const input = {
action: 'fire-dom-event',
advanced_camera_card_action: 'status_bar',
status_bar_action: 'reset',
items: [
{
enabled: true,
exclusive: false,
expand: false,
permanent: false,
priority: 50,
sufficient: false,
type: 'custom:advanced-camera-card-status-bar-string',
string: 'Item',
},
],
};
expect(statusBarActionConfigSchema.parse(input)).toEqual(input);
});
it('should recursively validate if action then/else sequences', () => {
const input = {
if: [
{
condition: 'state',
entity_id: 'light.office_main_lights',
state: 'on',
},
],
then: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_on',
},
],
else: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_off',
},
],
};
expect(actionConfigSchema.parse(input)).toEqual(input);
// The then/else sequences are validated as actions (not accepted verbatim).
expect(
actionConfigSchema.safeParse({ ...input, then: [{ action: 'not-an-action' }] })
.success,
).toBeFalsy();
});
it('should normalize single if/then/else items to lists', () => {
const result = actionConfigSchema.parse({
if: { condition: 'state', entity_id: 'light.office', state: 'on' },
then: {
action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_on',
},
else: {
action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_off',
},
});
expect(result).toMatchObject({
if: [{ condition: 'state', entity_id: 'light.office', state: 'on' }],
then: [
{ action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_on' },
],
else: [
{ action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_off' },
],
});
});
});
describe('should apply specific custom action schemas, not the generic catch-all', () => {
it('should default a log action level to info', () => {
// The generic `customActionSchema` (a loose `fire-dom-event` matcher) must
// not shadow `logActionConfigSchema`, or the `level` default is lost and the
// action crashes at runtime.
expect(
actionConfigSchema.parse({
action: 'fire-dom-event',
advanced_camera_card_action: 'log',
message: 'hello',
}),
).toMatchObject({ advanced_camera_card_action: 'log', level: 'info' });
});
it('should default a log action level nested in an if branch', () => {
const result = actionConfigSchema.parse({
if: { condition: 'state', entity_id: 'light.office', state: 'on' },
then: {
action: 'fire-dom-event',
advanced_camera_card_action: 'log',
message: 'hello',
},
});
expect(result).toMatchObject({
then: [{ advanced_camera_card_action: 'log', level: 'info' }],
});
});
});
describe('should handle custom advanced camera card elements', () => {
it('should reject non-custom element types', () => {
const result = customSchema.safeParse({
type: 'foo',
});
expect(result.success).toBeFalsy();
});
it('should add custom error on advanced camera card element', () => {
const result = customSchema.safeParse({
type: 'custom:advanced-camera-card-foo',
});
expect(result.success).toBeFalsy();
if (!result.success) {
expect(result.error.issues[0]).toEqual({
code: 'custom',
message: 'advanced-camera-card custom elements must match specific schemas',
fatal: true,
path: ['type'],
});
}
});
it('should not add custom error on valid entry', () => {
const result = customSchema.safeParse({
type: 'custom:foo',
});
expect(result.success).toBeTruthy();
});
});
// https://github.com/dermotduffy/advanced-camera-card/issues/1280
it('should not require title controls to specify all options', () => {
expect(
createConfig({
cameras: [{}],
live: {
controls: {
title: {
mode: 'popup-top-left',
},
},
},
}),
).toBeTruthy();
});
it('should allow the on-screen call controls overlay to be disabled', () => {
const config = createConfig({
cameras: [{}],
live: {
controls: {
call: {
enabled: false,
},
},
},
});
expect(config.live.controls.call.enabled).toBe(false);
});
it('should strip trailing slashes from go2rtc url', () => {
const config = createConfig({
cameras: [
{
go2rtc: {
url: 'https://my-custom-go2rtc//',
},
},
],
});
expect(config).toBeTruthy();
expect(config.cameras?.[0].go2rtc.url).toBe('https://my-custom-go2rtc');
});
it('media viewer should not support microphone based conditions', () => {
expect(() =>
createConfig({
cameras: [],
media_viewer: {
auto_unmute: 'microphone' as const,
},
}),
).toThrow();
});
describe('automations should require actions', () => {
it('should reject a missing actions key', () => {
expect(() =>
createConfig({
cameras: [{}],
automations: [{ triggers: [{ trigger: 'initialized' }], conditions: [] }],
}),
).toThrow();
});
});
describe('automations should accept Home Assistant input shorthands', () => {
it('should normalize singular keys and single items to lists', () => {
const result = automationsSchema.parse([
{
trigger: { trigger: 'state', entity_id: 'binary_sensor.door', to: 'on' },
condition: { condition: 'state', entity_id: 'input_boolean.x', state: 'on' },
action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_on',
},
},
]);
expect(result).toMatchObject([
{
triggers: [{ trigger: 'state', entity_id: 'binary_sensor.door', to: 'on' }],
conditions: [{ condition: 'state', entity_id: 'input_boolean.x', state: 'on' }],
actions: [
{ action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_on' },
],
},
]);
});
it('should accept a conditions key written with no value', () => {
const result = automationsSchema.parse([
{
triggers: [{ trigger: 'initialized' }],
conditions: null,
actions: [
{ action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_on' },
],
},
]);
expect(result).toMatchObject([{ conditions: [] }]);
});
it('should keep the plural key when both singular and plural are given', () => {
const result = automationsSchema.parse([
{
triggers: [{ trigger: 'initialized' }],
trigger: { trigger: 'state', entity_id: 'x', to: 'on' },
actions: [
{ action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_on' },
],
},
]);
expect(result[0].triggers).toEqual([{ trigger: 'initialized', ever: false }]);
});
it('should reject a non-object automation', () => {
expect(automationsSchema.safeParse(['not-an-object']).success).toBe(false);
});
});