diff --git a/docs/configuration/profiles.md b/docs/configuration/profiles.md index 7d7e17d2..c98eecc4 100644 --- a/docs/configuration/profiles.md +++ b/docs/configuration/profiles.md @@ -16,11 +16,12 @@ profiles: > [!NOTE] > Profiles are applied top to bottom. If multiple profiles change a configuration default, then the last one "wins" -| Profile name | Purpose | -| ----------------- | ---------------------------------------------- | -| `casting` | Configure the card to be casted. | -| `low-performance` | Configure the card for lower end devices. | -| `scrubbing` | Configure the card to allow "video scrubbing". | +| Profile name | Purpose | +| ----------------- | ------------------------------------------------------------ | +| `casting` | Configure the card to be casted. | +| `doorbell` | Configure the card to ring like a phone on a doorbell press. | +| `low-performance` | Configure the card for lower end devices. | +| `scrubbing` | Configure the card to allow "video scrubbing". | ## `casting` @@ -32,6 +33,27 @@ profiles: - casting ``` +## `doorbell` + +Turns the card into a phone-style ringer that answers a [two-way audio +call](../usage/2-way-audio.md) when somebody presses a doorbell. Intended for a +wall-mounted tablet sitting on a dashboard with a doorbell camera. + +Minimal configuration — just point it at a doorbell-capable camera: + +```yaml +type: custom:advanced-camera-card +cameras: + - camera_entity: camera.front_door +profiles: + - doorbell +``` + +> [!NOTE] +> For inbound audio to actually work the camera still needs a live provider that supports two-way audio (e.g. `go2rtc` with `webrtc` mode). The profile does not change `live_provider` since that's setup-specific. + +See the [source code](https://github.com/dermotduffy/advanced-camera-card/blob/main/src/config/profiles/doorbell.ts) for an exhaustive list of options set by this profile. + ## `low-performance` For low end devices, the `low-performance` profile will adjust card defaults to attempt to increase performance. @@ -56,6 +78,7 @@ See the [source code](https://github.com/dermotduffy/advanced-camera-card/blob/m ```yaml profiles: - casting + - doorbell - low-performance - scrubbing ``` diff --git a/docs/examples.md b/docs/examples.md index 293853f8..31d8d130 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -361,46 +361,26 @@ elements: ### Inbound call on doorbell press -This example uses [`view.triggers.actions.trigger: call`](configuration/view.md?id=triggers) to turn a dashboard into a phone-like ringer when somebody presses the doorbell. The intended deployment is a wall-mounted tablet sitting on the dashboard. - -Setting `triggers.doorbell: true` opts the camera into auto-discovery of [HA -`event.*` -entities](https://www.home-assistant.io/integrations/event/#device-class) with -`device_class: doorbell` on the camera's device — the officially supported way -modern integrations (Ring, UniFi Protect, Nest, DoorBird, Reolink, etc.) expose -a doorbell press -- no need to list the entity explicitly under -`triggers.entities`. If your doorbell exposes a `binary_sensor.*` or `switch.*` -instead, list it under `triggers.entities` manually. +The [`doorbell` profile](configuration/profiles.md?id=doorbell) turns a dashboard into a phone-like ringer when somebody presses the doorbell, by setting [`view.triggers.actions.trigger: call`](configuration/view.md?id=triggers) and auto-discovering [HA `event.*` entities](https://www.home-assistant.io/integrations/event/#device-class) with `device_class: doorbell` on the camera's device (Ring, UniFi Protect, Nest, DoorBird, Reolink, etc.). The intended deployment is a wall-mounted tablet sitting on the dashboard. A doorbell press is instantaneous, so the card synthesises a ring window from [`view.triggers.signal_hold_seconds`](configuration/view.md?id=triggers) (default `30`s) — long enough for a typical phone-style answer window. `untrigger_delay_seconds` then lingers past that, same as for any stateful trigger. -`triggers.motion`, `triggers.occupancy`, and `triggers.events` are off by default — only the explicit doorbell press triggers the call, so casual motion won't make the card ring. - ```yaml type: custom:advanced-camera-card cameras: - camera_entity: camera.front_door + # `go2rtc` + `webrtc` is needed for two-way audio. live_provider: go2rtc go2rtc: modes: - webrtc - triggers: - doorbell: true -view: - default: live - triggers: - show_trigger_status: true - actions: - # Call when triggered. - trigger: call - # When the trigger naturally ends (after signal_hold_seconds): end - # the call if it's still ringing. An answered call survives this and - # must be ended manually. - untrigger: call - # Ring even when somebody is actively using the card. - interaction_mode: all +profiles: + - doorbell ``` +> [!TIP] +> If your doorbell exposes a `binary_sensor.*` or `switch.*` instead of an `event.*` entity, list it under [`triggers.entities`](configuration/cameras/README.md?id=triggers) on the camera manually. Auto-discovery only covers `event.*` based doorbell entities. + ## Events from other cameras `dependencies.cameras` allows events/recordings for other cameras to be shown diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index ba6b50ec..d8c2af34 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -229,7 +229,9 @@ export class CameraManager { } const hasAutoTriggers = (config: CameraConfig): boolean => { - return config.triggers.motion || config.triggers.occupancy; + return ( + config.triggers.motion || config.triggers.occupancy || config.triggers.doorbell + ); }; if ( diff --git a/src/config/profiles/casting.ts b/src/config/profiles/casting.ts index 9b09251c..6eaaa396 100644 --- a/src/config/profiles/casting.ts +++ b/src/config/profiles/casting.ts @@ -30,9 +30,6 @@ export const CASTING_PROFILE = { [CONF_DIMENSIONS_ASPECT_RATIO_MODE]: 'static', [CONF_DIMENSIONS_ASPECT_RATIO]: '16:9', - // These values are defaults anyway unless another profile (e.g. - // low-performance) is also selected, but at pretty important to a good - // experience so are reset here. [CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS]: 1, [CONF_LIVE_SHOW_IMAGE_DURING_LOAD]: true, }; diff --git a/src/config/profiles/doorbell.ts b/src/config/profiles/doorbell.ts new file mode 100644 index 00000000..3f546ddf --- /dev/null +++ b/src/config/profiles/doorbell.ts @@ -0,0 +1,23 @@ +import { + CONF_CAMERAS_GLOBAL_TRIGGERS_DOORBELL, + CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE, + CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, + CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, + CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS, +} from '../../const.js'; + +export const DOORBELL_PROFILE = { + // Auto-discover HA `event.*` entities with `device_class: doorbell` on each + // camera's device (Ring, UniFi Protect, Nest, DoorBird, Reolink, ...). + [CONF_CAMERAS_GLOBAL_TRIGGERS_DOORBELL]: true, + + // Pulse the camera border while the doorbell is ringing. + [CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS]: true, + + // Triggers cause calls to start and end. + [CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER]: 'call' as const, + [CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER]: 'call' as const, + + // Ring even while the user is actively touching the dashboard. + [CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE]: 'all' as const, +}; diff --git a/src/config/profiles/low-performance.ts b/src/config/profiles/low-performance.ts index ad3eb24f..61040162 100644 --- a/src/config/profiles/low-performance.ts +++ b/src/config/profiles/low-performance.ts @@ -1,31 +1,24 @@ import { CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS, CONF_CAMERAS_GLOBAL_LIVE_PROVIDER, - CONF_CAMERAS_GLOBAL_TRIGGERS_OCCUPANCY, CONF_LIVE_AUTO_MUTE, CONF_LIVE_CONTROLS_THUMBNAILS_MODE, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, - CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, - CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS, CONF_LIVE_DRAGGABLE, CONF_LIVE_LAZY_UNLOAD, CONF_LIVE_SHOW_IMAGE_DURING_LOAD, CONF_LIVE_TRANSITION_EFFECT, CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_MEDIA_VIEWER_AUTO_MUTE, CONF_MEDIA_VIEWER_AUTO_PAUSE, CONF_MEDIA_VIEWER_AUTO_PLAY, CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS, CONF_MEDIA_VIEWER_DRAGGABLE, CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP, @@ -44,9 +37,7 @@ import { CONF_STATUS_BAR_STYLE, CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_TIMELINE_SHOW_RECORDINGS, CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, } from '../../const.js'; @@ -94,28 +85,19 @@ export const LOW_PERFORMANCE_PROFILE = { // Hide several buttons that are otherwise visible by default. [`${CONF_MENU_BUTTONS_IRIS}.enabled`]: false, [`${CONF_MENU_BUTTONS_TIMELINE}.enabled`]: false, - [`${CONF_MENU_BUTTONS_TIMELINE}.enabled`]: false, // If the media player button is present media player entity fetches are // required on initialization. [`${CONF_MENU_BUTTONS_MEDIA_PLAYER}.enabled`]: false, - // Disable all options in thumbnails. - [CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL]: false, + // Disable thumbnail controls that are otherwise on by default. [CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false, - [CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false, [CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false, - [CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL]: false, [CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false, - [CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false, [CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false, - [CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL]: false, [CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false, - [CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false, [CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false, - [CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL]: false, [CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false, - [CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false, [CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false, // Disable all optional performance related features. @@ -133,7 +115,6 @@ export const LOW_PERFORMANCE_PROFILE = { // Clicking on a snapshot should not play a clip. [CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP]: false, - [CONF_CAMERAS_GLOBAL_TRIGGERS_OCCUPANCY]: false, [CONF_CAMERAS_GLOBAL_LIVE_PROVIDER]: 'image', // Refresh the live camera image every 10 seconds (same as stock Home diff --git a/src/config/profiles/set-profiles.ts b/src/config/profiles/set-profiles.ts index edc84e6b..caedef1a 100644 --- a/src/config/profiles/set-profiles.ts +++ b/src/config/profiles/set-profiles.ts @@ -4,11 +4,13 @@ import { ProfileType } from '../schema/profiles.js'; import { advancedCameraCardConfigSchema } from '../schema/types.js'; import { RawAdvancedCameraCardConfig } from '../types.js'; import { CASTING_PROFILE } from './casting.js'; +import { DOORBELL_PROFILE } from './doorbell.js'; import { LOW_PERFORMANCE_PROFILE } from './low-performance.js'; import { SCRUBBING_PROFILE } from './scrubbing.js'; const PROFILES = { casting: CASTING_PROFILE, + doorbell: DOORBELL_PROFILE, 'low-performance': LOW_PERFORMANCE_PROFILE, scrubbing: SCRUBBING_PROFILE, }; diff --git a/src/config/schema/profiles.ts b/src/config/schema/profiles.ts index 9db1b174..be43fcab 100644 --- a/src/config/schema/profiles.ts +++ b/src/config/schema/profiles.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -const PROFILES = ['casting', 'low-performance', 'scrubbing'] as const; +const PROFILES = ['casting', 'doorbell', 'low-performance', 'scrubbing'] as const; export type ProfileType = (typeof PROFILES)[number]; export const profilesSchema = z.enum(PROFILES).array().optional(); diff --git a/src/const.ts b/src/const.ts index baa70c51..3056b187 100644 --- a/src/const.ts +++ b/src/const.ts @@ -132,8 +132,8 @@ export const CONF_CAMERAS_ARRAY_MEDIA_FOLDERS = const CONF_CAMERAS_GLOBAL = 'cameras_global' as const; export const CONF_CAMERAS_GLOBAL_LIVE_PROVIDER = `${CONF_CAMERAS_GLOBAL}.live_provider` as const; -export const CONF_CAMERAS_GLOBAL_TRIGGERS_OCCUPANCY = - `${CONF_CAMERAS_GLOBAL}.triggers.occupancy` as const; +export const CONF_CAMERAS_GLOBAL_TRIGGERS_DOORBELL = + `${CONF_CAMERAS_GLOBAL}.triggers.doorbell` as const; export const CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS = `${CONF_CAMERAS_GLOBAL}.image.refresh_seconds` as const; export const CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT = diff --git a/src/editor.ts b/src/editor.ts index ac93e50c..1b20d73e 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -850,6 +850,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard private _profiles: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'casting', label: localize('config.profiles.casting') }, + { value: 'doorbell', label: localize('config.profiles.doorbell') }, { value: 'low-performance', label: localize('config.profiles.low-performance') }, { value: 'scrubbing', label: localize('config.profiles.scrubbing') }, ]; diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index df2d20ab..93cb2244 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -580,6 +580,7 @@ }, "profiles": { "casting": "Casting", + "doorbell": "Doorbell", "editor_label": "Configuration profiles", "low-performance": "Low performance", "scrubbing": "Video scrubbing" diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 14dbb060..12d1045d 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -440,24 +440,27 @@ describe('CameraManager', () => { }); describe('should fetch entity list when required', () => { - it('should fetch with entity based trigger', async () => { - const api = createCardAPI(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + it.each([['occupancy' as const], ['motion' as const], ['doorbell' as const]])( + 'should fetch with %s trigger', + async (triggerKey) => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - const manager = createCameraManager(api, mock(), [ - { - config: createCameraConfig({ - ...baseCameraConfig, - triggers: { - occupancy: true, - }, - }), - }, - ]); + const manager = createCameraManager(api, mock(), [ + { + config: createCameraConfig({ + ...baseCameraConfig, + triggers: { + [triggerKey]: true, + }, + }), + }, + ]); - await manager.initializeCamerasFromConfig(); - expect(api.getEntityRegistryManager().fetchEntityList).toBeCalled(); - }); + await manager.initializeCamerasFromConfig(); + expect(api.getEntityRegistryManager().fetchEntityList).toBeCalled(); + }, + ); it('should skip without entity based trigger', async () => { const api = createCardAPI(); diff --git a/tests/config/profiles/doorbell.test.ts b/tests/config/profiles/doorbell.test.ts new file mode 100644 index 00000000..147d8add --- /dev/null +++ b/tests/config/profiles/doorbell.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'vitest'; +import { DOORBELL_PROFILE } from '../../../src/config/profiles/doorbell'; +import { setProfiles } from '../../../src/config/profiles/set-profiles'; +import { advancedCameraCardConfigSchema } from '../../../src/config/schema/types'; +import { createRawConfig } from '../../test-utils'; + +it('should contain expected defaults', () => { + expect(DOORBELL_PROFILE).toEqual({ + 'cameras_global.triggers.doorbell': true, + 'view.triggers.actions.interaction_mode': 'all', + 'view.triggers.actions.trigger': 'call', + 'view.triggers.actions.untrigger': 'call', + 'view.triggers.show_trigger_status': true, + }); +}); + +it('should be parseable after application', () => { + const rawInputConfig = createRawConfig(); + const parsedConfig = advancedCameraCardConfigSchema.parse(rawInputConfig); + + setProfiles(rawInputConfig, parsedConfig, ['doorbell']); + + // Reparse the config to ensure the profile did not introduce errors. + const parseResult = advancedCameraCardConfigSchema.safeParse(parsedConfig); + expect(parseResult.success, parseResult.error?.toString()).toBeTruthy(); +}); diff --git a/tests/config/profiles/low-performance.test.ts b/tests/config/profiles/low-performance.test.ts index 89aaa56d..3c48496a 100644 --- a/tests/config/profiles/low-performance.test.ts +++ b/tests/config/profiles/low-performance.test.ts @@ -8,31 +8,24 @@ it('should contain expected defaults', () => { expect(LOW_PERFORMANCE_PROFILE).toEqual({ 'cameras_global.image.refresh_seconds': 10, 'cameras_global.live_provider': 'image', - 'cameras_global.triggers.occupancy': false, 'live.auto_mute': [], 'live.controls.thumbnails.mode': 'none', 'live.controls.thumbnails.show_details': false, - 'live.controls.thumbnails.show_download_control': false, 'live.controls.thumbnails.show_favorite_control': false, - 'live.controls.thumbnails.show_timeline_control': false, 'live.controls.timeline.show_recordings': false, 'live.draggable': false, 'live.lazy_unload': ['unselected', 'hidden'], 'live.show_image_during_load': false, 'live.transition_effect': 'none', 'media_gallery.controls.thumbnails.show_details': false, - 'media_gallery.controls.thumbnails.show_download_control': false, 'media_gallery.controls.thumbnails.show_favorite_control': false, - 'media_gallery.controls.thumbnails.show_timeline_control': false, 'media_viewer.auto_mute': [], 'media_viewer.auto_pause': [], 'media_viewer.auto_play': [], 'media_viewer.controls.next_previous.style': 'chevrons', 'media_viewer.controls.thumbnails.mode': 'none', 'media_viewer.controls.thumbnails.show_details': false, - 'media_viewer.controls.thumbnails.show_download_control': false, 'media_viewer.controls.thumbnails.show_favorite_control': false, - 'media_viewer.controls.thumbnails.show_timeline_control': false, 'media_viewer.controls.timeline.show_recordings': false, 'media_viewer.draggable': false, 'media_viewer.snapshot_click_plays_clip': false, @@ -51,9 +44,7 @@ it('should contain expected defaults', () => { 'status_bar.style': 'none', 'timeline.controls.thumbnails.mode': 'none', 'timeline.controls.thumbnails.show_details': false, - 'timeline.controls.thumbnails.show_download_control': false, 'timeline.controls.thumbnails.show_favorite_control': false, - 'timeline.controls.thumbnails.show_timeline_control': false, 'timeline.show_recordings': false, 'view.triggers.actions.trigger': 'none', });