diff --git a/README.md b/README.md index 6c1ba897..8d1bc9e5 100644 --- a/README.md +++ b/README.md @@ -1293,6 +1293,7 @@ All variables listed are under a `conditions:` section. | `media_query` | Any valid [media query](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries) string. Media queries must start and end with parentheses. This may be used to alter card configuration based on device/media properties (e.g. viewport width, orientation). Please note that `width` and `height` refer to the entire viewport not just the card. See the [media query example](#media-query-example).| | `interacted` | If `true` the condition is satisfied if the card has had human interaction within `view.interaction_seconds` elapsed seconds. If `false` the condition is satisfied if the card has **NOT** had human interaction in that time. | | `triggered` | A list of camera IDs which, if triggered in [scan mode](#scan-mode), satisfy the condition.| +| `microphone` | A object to include microphone state as part of the condition evaluation. See below.| See the [example below](#frigate-card-conditional-example) for a real-world example of how these conditions can be used. @@ -1316,6 +1317,18 @@ If multiple entries are provided, the results are `AND`ed. See the [Menu override example below](#frigate-card-menu-override-example) for an illustration. +### Microphone Conditions + +```yaml +- conditions: + microphone: +``` + +| Parameter | Description | +| - | - | +| `muted` | Optional: If `true` or `false` the condition is satisfied if the microphone is muted or unmuted respectively. | +| `connected` | Optional: If `true` or `false` the condition is satisfied if the microphone is connected or disconnected respectively. | + ## Picture Elements / Menu Customizations diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index e984cb48..cbbc6107 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -60,6 +60,7 @@ export class CardElementManager { this._api.getFullscreenManager().initialize(); this._api.getExpandManager().initialize(); this._api.getMediaLoadedInfoManager().initialize(); + this._api.getMicrophoneManager().initialize(); // Whether or not the card is in panel mode on the dashboard. setOrRemoveAttribute(this._element, isCardInPanel(this._element), 'panel'); diff --git a/src/card-controller/conditions-manager.ts b/src/card-controller/conditions-manager.ts index 7a726f71..7072323b 100644 --- a/src/card-controller/conditions-manager.ts +++ b/src/card-controller/conditions-manager.ts @@ -4,6 +4,7 @@ import { copyConfig } from '../config-mgmt'; import { FrigateCardCondition, frigateConditionalSchema, + MicrophoneConditionState, OverrideConfigurationKey, RawFrigateCardConfig, ViewDisplayMode, @@ -20,6 +21,7 @@ interface ConditionState { displayMode?: ViewDisplayMode; triggered?: Set; interaction?: boolean; + microphone?: MicrophoneConditionState; } export class ConditionEvaluateRequestEvent extends Event { @@ -254,6 +256,13 @@ export class ConditionsManager { result &&= state.interaction !== undefined && condition.interaction === state.interaction; } + if (condition.microphone) { + result &&= + (condition.microphone.connected === undefined || + state.microphone?.connected === condition.microphone.connected) && + (condition.microphone.muted === undefined || + state.microphone?.muted === condition.microphone.muted); + } return result; } diff --git a/src/card-controller/microphone-manager.ts b/src/card-controller/microphone-manager.ts index 8aed6315..19bc7c5f 100644 --- a/src/card-controller/microphone-manager.ts +++ b/src/card-controller/microphone-manager.ts @@ -29,6 +29,10 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager { this._api = api; } + public initialize(): void { + this._setConditionState(); + } + public async connect(): Promise { try { this._stream = await navigator.mediaDevices.getUserMedia({ @@ -43,13 +47,15 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager { return false; } this._setMute(); + this._setConditionState(); return true; } - public async disconnect(): Promise { + public disconnect(): void { this._stream?.getTracks().forEach((track) => track.stop()); this._stream = undefined; + this._setConditionState(); this._api.getCardElementManager().update(); } @@ -62,6 +68,7 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager { this._mute = true; this._setMute(); + this._setConditionState(); if (!wasMuted) { this._callListeners('muted'); @@ -89,6 +96,7 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager { unmute(); } + this._setConditionState(); if (!wasUnmuted) { this._callListeners('unmuted'); } @@ -144,4 +152,13 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager { }); } } + + protected _setConditionState(): void { + this._api.getConditionsManager().setState({ + microphone: { + muted: this.isMuted(), + connected: this.isConnected(), + }, + }); + } } diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 7cc92bba..89122b00 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -104,6 +104,7 @@ export interface CardElementAPI { getFullscreenManager(): FullscreenManager; getInteractionManager(): InteractionManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; + getMicrophoneManager(): MicrophoneManager; getQueryStringManager(): QueryStringManager; } @@ -178,6 +179,7 @@ export interface CardMessageAPI { export interface CardMicrophoneAPI { getCardElementManager(): CardElementManager; + getConditionsManager(): ConditionsManager; getConfigManager(): ConfigManager; } diff --git a/src/config/types.ts b/src/config/types.ts index 19b87484..bbd36b2e 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -479,6 +479,12 @@ export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu | MenuSubmenuSelec // Custom Element Configuration: Conditions // ************************************************************************* +const microphoneConditionSchema = z.object({ + connected: z.boolean().optional(), + muted: z.boolean().optional(), +}); +export type MicrophoneConditionState = z.infer; + export const frigateCardConditionSchema = z.object({ view: z.string().array().optional(), fullscreen: z.boolean().optional(), @@ -490,6 +496,7 @@ export const frigateCardConditionSchema = z.object({ display_mode: viewDisplayModeSchema.optional(), triggered: z.string().array().optional(), interaction: z.boolean().optional(), + microphone: microphoneConditionSchema.optional(), }); export type FrigateCardCondition = z.infer; @@ -736,7 +743,7 @@ export type LiveProvider = (typeof LIVE_PROVIDERS)[number]; const microphoneConfigDefault = { always_connected: false, - disconnect_seconds: 60, + disconnect_seconds: 90, mute_after_microphone_mute_seconds: 60, }; diff --git a/tests/card-controller/card-element-manager.test.ts b/tests/card-controller/card-element-manager.test.ts index 1f9877a1..89a5e8e1 100644 --- a/tests/card-controller/card-element-manager.test.ts +++ b/tests/card-controller/card-element-manager.test.ts @@ -141,6 +141,7 @@ describe('CardElementManager', () => { expect(api.getFullscreenManager().initialize).toBeCalled(); expect(api.getExpandManager().initialize).toBeCalled(); expect(api.getMediaLoadedInfoManager().initialize).toBeCalled(); + expect(api.getMicrophoneManager().initialize).toBeCalled(); }); it('should disconnect', () => { diff --git a/tests/card-controller/conditions-manager.test.ts b/tests/card-controller/conditions-manager.test.ts index f1c321ec..08e71c67 100644 --- a/tests/card-controller/conditions-manager.test.ts +++ b/tests/card-controller/conditions-manager.test.ts @@ -377,9 +377,81 @@ describe('ConditionsManager', () => { const manager = new ConditionsManager(createCardAPI()); const condition: FrigateCardCondition = { interaction: true }; expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ interaction: true }) + manager.setState({ interaction: true }); expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ interaction: false }) + manager.setState({ interaction: false }); expect(manager.evaluateCondition(condition)).toBeFalsy(); }); + + describe('should evaluate conditions with microphone', () => { + it('empty', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { microphone: {} }; + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ microphone: { connected: true } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ microphone: { connected: false } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ microphone: { muted: true } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ microphone: { muted: false } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + }); + + it('connected is true', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { microphone: { connected: true } }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { connected: true } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ microphone: { connected: false } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('connected is false', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { microphone: { connected: false } }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { connected: true } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { connected: false } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + }); + + it('muted is true', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { microphone: { muted: true } }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { muted: true } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ microphone: { muted: false } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('muted is false', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { microphone: { muted: false } }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { muted: true } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { muted: false } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + }); + + it('connected and muted', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { + microphone: { muted: false, connected: true }, + }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { muted: true } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { muted: false } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { connected: false, muted: false } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { connected: true, muted: false } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + }); + }); }); diff --git a/tests/card-controller/microphone-manager.test.ts b/tests/card-controller/microphone-manager.test.ts index df9d6408..c3d521e5 100644 --- a/tests/card-controller/microphone-manager.test.ts +++ b/tests/card-controller/microphone-manager.test.ts @@ -128,7 +128,7 @@ describe('MicrophoneManager', () => { expect(manager.isConnected()).toBeTruthy(); expect(api.getCardElementManager().update).toBeCalledTimes(1); - await manager.disconnect(); + manager.disconnect(); expect(manager.isConnected()).toBeFalsy(); expect(api.getCardElementManager().update).toBeCalledTimes(2); }); @@ -202,7 +202,7 @@ describe('MicrophoneManager', () => { await manager.connect(); expect(listener).not.toHaveBeenCalled(); - await manager.mute(); + manager.mute(); expect(listener).not.toHaveBeenCalled(); await manager.unmute(); @@ -212,7 +212,7 @@ describe('MicrophoneManager', () => { await manager.unmute(); expect(listener).toHaveBeenCalledTimes(1); - await manager.mute(); + manager.mute(); expect(listener).toHaveBeenCalledTimes(2); expect(listener).toHaveBeenLastCalledWith('muted'); @@ -221,4 +221,62 @@ describe('MicrophoneManager', () => { await manager.unmute(); expect(listener).toHaveBeenCalledTimes(2); }); + + it('should initialize', () => { + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + + manager.initialize(); + expect(api.getConditionsManager().setState).toBeCalledWith({ + microphone: { connected: false, muted: true }, + }); + }); + + it('should set condition state', async () => { + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); + + expect(api.getConditionsManager().setState).not.toBeCalled(); + + await manager.connect(); + expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith( + expect.objectContaining({ + microphone: { + connected: true, + muted: true, + }, + }), + ); + + await manager.unmute(); + expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith( + expect.objectContaining({ + microphone: { + connected: true, + muted: false, + }, + }), + ); + + manager.mute(); + expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith( + expect.objectContaining({ + microphone: { + connected: true, + muted: true, + }, + }), + ); + + manager.disconnect(); + expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith( + expect.objectContaining({ + microphone: { + connected: false, + muted: true, + }, + }), + ); + }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index aa41d7b8..ef597865 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -99,7 +99,7 @@ describe('config defaults', () => { lazy_unload: [], microphone: { always_connected: false, - disconnect_seconds: 60, + disconnect_seconds: 90, mute_after_microphone_mute_seconds: 60, }, preload: false, diff --git a/vite.config.ts b/vite.config.ts index 197f27f1..c068a35d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,10 +10,10 @@ export default defineConfig({ // Thresholds will automatically be updated as coverage improves to avoid // back-sliding. thresholdAutoUpdate: true, - statements: 72.66, - branches: 61.9, - functions: 73.96, - lines: 72.56, + statements: 72.72, + branches: 61.97, + functions: 74.01, + lines: 72.62, }, }, });