feat: Add UI optional UI locking when microphone is hot (#2484)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 9a763db1f3
commit be7e7d79dc
45 changed files with 922 additions and 53 deletions
@@ -14,11 +14,21 @@ import {
Interaction,
InteractionName,
} from '../../../src/card-controller/actions/actions-manager';
import type { CardController } from '../../../src/card-controller/controller';
import { TemplateRenderer } from '../../../src/card-controller/templates';
import { AdvancedCameraCardView } from '../../../src/config/schema/common/const';
import { createLogAction } from '../../../src/utils/action';
import { createGeneralAction, createLogAction } from '../../../src/utils/action';
import { arrayify } from '../../../src/utils/basic';
import { createCardAPI, createConfig, createHASS, createView } from '../../test-utils';
const createAPI = (): CardController => {
const api = createCardAPI();
vi.mocked(api.getLockManager().getAllowedActions).mockImplementation((actions) =>
arrayify(actions),
);
return api;
};
describe('ActionsManager', () => {
describe('getMergedActions', () => {
const config = {
@@ -69,7 +79,7 @@ describe('ActionsManager', () => {
});
it('should get no merged actions with an issue', () => {
const api = createCardAPI();
const api = createAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ view: 'live' }),
);
@@ -142,7 +152,7 @@ describe('ActionsManager', () => {
],
['timeline' as const, {}],
])('%s', (viewName: AdvancedCameraCardView, result: Record<string, unknown>) => {
const api = createCardAPI();
const api = createAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ view: viewName }),
);
@@ -162,9 +172,12 @@ describe('ActionsManager', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should handle interaction', async () => {
const api = createCardAPI();
const api = createAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
@@ -193,7 +206,7 @@ describe('ActionsManager', () => {
it.each([['malformed_type_of_tap' as const], ['double_tap' as const]])(
'%s',
(interaction: string) => {
const api = createCardAPI();
const api = createAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
@@ -227,6 +240,9 @@ describe('ActionsManager', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should handle advanced camera card event', async () => {
const action = createLogAction('Hello, world!');
@@ -234,7 +250,7 @@ describe('ActionsManager', () => {
detail: action,
});
const api = createCardAPI();
const api = createAPI();
const manager = new ActionsManager(api);
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
@@ -254,7 +270,7 @@ describe('ActionsManager', () => {
const handler = vi.fn();
card.addEventListener('ll-custom', handler);
const api = createCardAPI();
const api = createAPI();
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(card);
const manager = new ActionsManager(api);
@@ -264,7 +280,7 @@ describe('ActionsManager', () => {
});
it('should not handle event without detail', async () => {
const manager = new ActionsManager(createCardAPI());
const manager = new ActionsManager(createAPI());
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
await manager.handleCustomActionEvent(new Event('ll-custom'));
@@ -273,8 +289,12 @@ describe('ActionsManager', () => {
});
describe('handleActionExecutionRequestEvent', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should execute actions', async () => {
const api = createCardAPI();
const api = createAPI();
const manager = new ActionsManager(api);
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
@@ -288,8 +308,12 @@ describe('ActionsManager', () => {
});
describe('executeAction', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should execute actions', async () => {
const api = createCardAPI();
const api = createAPI();
const manager = new ActionsManager(api);
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
@@ -298,7 +322,7 @@ describe('ActionsManager', () => {
});
it('should execute actions', async () => {
const api = createCardAPI();
const api = createAPI();
const manager = new ActionsManager(api);
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
@@ -312,7 +336,7 @@ describe('ActionsManager', () => {
const templateRenderer = mock<TemplateRenderer>();
templateRenderer.renderRecursively.mockReturnValue(action);
const api = createCardAPI();
const api = createAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
@@ -324,6 +348,7 @@ describe('ActionsManager', () => {
const manager = new ActionsManager(api, templateRenderer);
const config = { entity: 'light.office' };
const triggerData = { view: { from: 'previous-view', to: 'view' } };
vi.spyOn(global.console, 'info').mockReturnValue(undefined);
await manager.executeActions({ actions: action, config, triggerData });
@@ -333,6 +358,40 @@ describe('ActionsManager', () => {
});
});
it('should filter rendered actions through the lock manager', async () => {
const renderedAction = createGeneralAction('reload');
const allowedAction = createLogAction('Allowed');
const templateRenderer = mock<TemplateRenderer>();
templateRenderer.renderRecursively.mockReturnValue(renderedAction);
const api = createAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getLockManager().getAllowedActions).mockReturnValue([allowedAction]);
const manager = new ActionsManager(api, templateRenderer);
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
await manager.executeActions({
actions: createLogAction('{{ action }}'),
});
expect(api.getLockManager().getAllowedActions).toBeCalledWith(renderedAction);
expect(consoleSpy).toBeCalledWith('Allowed');
});
it('should not execute actions when the lock manager rejects them', async () => {
const api = createAPI();
vi.mocked(api.getLockManager().getAllowedActions).mockReturnValue([]);
const manager = new ActionsManager(api);
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
await manager.executeActions({ actions: createLogAction('Blocked') });
expect(consoleSpy).not.toBeCalled();
});
describe('should forward haptics', () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -343,7 +402,7 @@ describe('ActionsManager', () => {
const handler = vi.fn();
window.addEventListener('haptic', handler);
const api = createCardAPI();
const api = createAPI();
const manager = new ActionsManager(api);
await manager.executeActions({ actions: { action: 'none' } });
@@ -357,7 +416,7 @@ describe('ActionsManager', () => {
const handler = vi.fn();
window.addEventListener('haptic', handler);
const api = createCardAPI();
const api = createAPI();
const manager = new ActionsManager(api);
vi.stubGlobal('confirm', vi.fn().mockReturnValue(false));
@@ -375,12 +434,15 @@ describe('ActionsManager', () => {
beforeAll(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(() => {
vi.useRealTimers();
});
it('should stop actions', async () => {
const api = createCardAPI();
const api = createAPI();
const manager = new ActionsManager(api);
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
+8
View File
@@ -18,6 +18,7 @@ import { InitializationManager } from '../../src/card-controller/initialization-
import { InteractionManager } from '../../src/card-controller/interaction-manager';
import { IssueManager } from '../../src/card-controller/issues/issue-manager';
import { KeyboardStateManager } from '../../src/card-controller/keyboard-state-manager';
import { LockManager } from '../../src/card-controller/lock/manager';
import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-manager';
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager';
import { MicrophoneManager } from '../../src/card-controller/microphone-manager';
@@ -50,6 +51,7 @@ vi.mock('../../src/card-controller/hass/hass-manager');
vi.mock('../../src/card-controller/initialization-manager');
vi.mock('../../src/card-controller/interaction-manager');
vi.mock('../../src/card-controller/keyboard-state-manager');
vi.mock('../../src/card-controller/lock/manager');
vi.mock('../../src/card-controller/media-info-manager');
vi.mock('../../src/card-controller/media-player-manager');
vi.mock('../../src/card-controller/microphone-manager');
@@ -209,6 +211,12 @@ describe('CardController', () => {
);
});
it('should return getLockManager', () => {
expect(createController().getLockManager()).toBe(
vi.mocked(LockManager).mock.instances[0],
);
});
it('should return getMediaLoadedInfoManager', () => {
expect(createController().getMediaLoadedInfoManager()).toBe(
vi.mocked(MediaLoadedInfoManager).mock.instances[0],
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it, vi } from 'vitest';
import { LockManager } from '../../../src/card-controller/lock/manager';
import {
createCameraAction,
createDisplayModeAction,
createGeneralAction,
createLogAction,
createMediaPlayerAction,
createViewAction,
} from '../../../src/utils/action';
import { createCardAPI } from '../../test-utils';
describe('LockManager', () => {
it('should report unlocked when no lock source is active', () => {
const api = createCardAPI();
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false);
expect(new LockManager(api).isLocked()).toBeFalsy();
});
it('should report locked when the microphone is locking', () => {
const api = createCardAPI();
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
expect(new LockManager(api).isLocked()).toBeTruthy();
});
it('should reuse lock manager epoch until the lock state changes', () => {
const api = createCardAPI();
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false);
const manager = new LockManager(api);
const unlockedEpoch = manager.getEpoch();
expect(manager.getEpoch()).toBe(unlockedEpoch);
expect(unlockedEpoch.locked).toBeFalsy();
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
const lockedEpoch = manager.getEpoch();
expect(lockedEpoch).not.toBe(unlockedEpoch);
expect(lockedEpoch.locked).toBeTruthy();
expect(manager.getEpoch()).toBe(lockedEpoch);
});
it('should not filter actions when unlocked', () => {
const api = createCardAPI();
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false);
const actions = [createGeneralAction('reload'), createLogAction('Allowed')];
expect(new LockManager(api).getAllowedActions(actions)).toBe(actions);
});
it('should reject microphone-session-disruptive actions when locked', () => {
const api = createCardAPI();
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
const manager = new LockManager(api);
for (const action of [
createViewAction('clips'),
createCameraAction('camera_select', 'cam-1'),
createCameraAction('live_substream_select', 'cam-1'),
createGeneralAction('live_substream_on'),
createGeneralAction('live_substream_off'),
createGeneralAction('default'),
createGeneralAction('pause'),
createGeneralAction('reload'),
createMediaPlayerAction('media_player.living_room', 'play'),
]) {
expect(manager.getAllowedActions(action)).toEqual([]);
}
});
it('should preserve non-disruptive actions when locked', () => {
const api = createCardAPI();
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
const manager = new LockManager(api);
for (const action of [
createGeneralAction('play'),
createGeneralAction('fullscreen'),
createGeneralAction('expand'),
createGeneralAction('pip'),
createGeneralAction('screenshot'),
createGeneralAction('mute'),
createGeneralAction('unmute'),
createGeneralAction('microphone_unmute'),
createDisplayModeAction('grid'),
{ action: 'none' as const },
]) {
expect(manager.getAllowedActions(action)).toEqual([action]);
}
});
it('should preserve non-disruptive actions from a mixed action list when locked', () => {
const api = createCardAPI();
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
const manager = new LockManager(api);
const allowedAction = createLogAction('Allowed');
expect(
manager.getAllowedActions([createGeneralAction('reload'), allowedAction]),
).toEqual([allowedAction]);
});
it('should report whether all configured actions are blocked', () => {
const api = createCardAPI();
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true);
const manager = new LockManager(api);
expect(
manager.areAllActionsBlocked({
tap_action: createGeneralAction('reload'),
hold_action: createGeneralAction('pause'),
}),
).toBeTruthy();
expect(
manager.areAllActionsBlocked({
tap_action: createGeneralAction('reload'),
hold_action: createLogAction('Allowed'),
}),
).toBeFalsy();
expect(manager.areAllActionsBlocked({})).toBeFalsy();
});
it('should never report all-actions-blocked when unlocked', () => {
const api = createCardAPI();
vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false);
const manager = new LockManager(api);
// Even when every action would be blocked under an active policy, an
// inactive lock short-circuits to false.
expect(
manager.areAllActionsBlocked({
tap_action: createGeneralAction('reload'),
hold_action: createGeneralAction('pause'),
}),
).toBeFalsy();
});
});
@@ -244,6 +244,82 @@ describe('MicrophoneManager', () => {
expect(api.getCardElementManager().update).toBeCalledTimes(1);
});
describe('isLocking', () => {
it('should not lock when muted', () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
lock: true,
},
},
}),
);
expect(manager.isMuted()).toBeTruthy();
expect(manager.isLocking()).toBeFalsy();
});
it('should lock when unmuted and lock is enabled', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
lock: true,
},
},
}),
);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
expect(manager.isLocking()).toBeTruthy();
});
it('should not lock when unmuted but lock is disabled', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
lock: false,
},
},
}),
);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
expect(manager.isLocking()).toBeFalsy();
});
it('should not lock when config is unavailable', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
expect(manager.isLocking()).toBeFalsy();
});
});
describe('should require initialization', async () => {
it('should require when configured and supported', async () => {
const api = createCardAPI();
@@ -163,6 +163,74 @@ describe('should not set view without cameras being initialized', () => {
});
});
describe('should respect microphone navigation lock', () => {
it('should ignore generic view changes when microphone is locking navigation', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView());
const api = createInitializedCardAPI();
vi.mocked(api.getLockManager().isLocked).mockReturnValue(true);
const manager = new ViewManager(api, { viewFactory: factory });
manager.setViewDefault();
expect(manager.getView()).toBeNull();
});
it('should ignore async view changes when microphone is locking navigation', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const api = createInitializedCardAPI();
vi.mocked(api.getLockManager().isLocked).mockReturnValue(true);
const manager = new ViewManager(api, {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()).toBeNull();
});
it('should allow generic view changes when force is set', () => {
const view = createView();
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(view);
const api = createInitializedCardAPI();
vi.mocked(api.getLockManager().isLocked).mockReturnValue(true);
const manager = new ViewManager(api, { viewFactory: factory });
manager.setViewDefault({ force: true });
expect(manager.getView()).toBe(view);
});
it('should allow async view changes when force is set', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView());
const viewQueryExecutor = mock<ViewQueryExecutor>();
viewQueryExecutor.getNewQueryModifiers.mockResolvedValue([]);
const api = createInitializedCardAPI();
vi.mocked(api.getLockManager().isLocked).mockReturnValue(true);
const manager = new ViewManager(api, {
viewFactory: viewFactory,
viewQueryExecutor: viewQueryExecutor,
});
await manager.setViewDefaultWithNewQuery({ force: true });
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
});
it('should set view default', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView());