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,
},
},
});