From ee23cb2b9d803f4f70544a882cce14ea4e868c87 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Mon, 9 Mar 2026 00:36:47 -0300 Subject: [PATCH] fix: automations on microphone connect not working (#2403) - Closes #2314 I used GPT-5.4 to investigate and generate this fix. I'm not sure if the code is good, but I can confirm it works. --------- Co-authored-by: dermotduffy --- src/card-controller/config/config-manager.ts | 77 ++++++++---- .../config/load-automations.ts | 2 +- .../config/config-manager.test.ts | 111 +++++++++++++++++- .../config/load-automations.test.ts | 2 +- 4 files changed, 164 insertions(+), 28 deletions(-) diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index 55c2b4ba..02cc1d94 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -138,32 +138,59 @@ export class ConfigManager { setFoldersFromConfig(this._api); this._api.getStyleManager().updateFromConfig(); - // Ensure features that register automations or other side-effects from - // configuration are updated when overrides change (e.g. remote_control). - // Re-run loaders that may add/remove automations based on the current - // effective configuration. - setKeyboardShortcutsFromConfig(this._api); - setRemoteControlEntityFromConfig(this._api); - setAutomationsFromConfig(this._api); + // Only re-run side-effect callbacks when their relevant config section + // changed. As an example of why: Automation loaders delete then recreate + // automations, which destroys associated ConditionsManagers. If a condition + // transition (e.g. microphone connect) triggers both a user automation and + // an unrelated override, an unconditional reload would delete the + // automation mid-transition — the freshly created replacement has no prior + // state, treats the current condition as its baseline, and never fires the + // action. + const runIfChanged = ( + extract: (config: AdvancedCameraCardConfig) => T, + callback: () => void, + skipFirst?: boolean, + ): void => { + if (!previousConfig) { + if (!skipFirst) { + callback(); + } + return; + } + if (!isEqual(extract(previousConfig), extract(overriddenConfig))) { + callback(); + } + }; - if ( - previousConfig && - (!isEqual(previousConfig?.cameras, this._overriddenConfig?.cameras) || - !isEqual(previousConfig?.cameras_global, this._overriddenConfig?.cameras_global)) - ) { - this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS); - this._api.getCameraManager().destroy(); - } - - if ( - previousConfig && - previousConfig?.live.microphone.always_connected !== - this._overriddenConfig?.live.microphone.always_connected - ) { - this._api - .getInitializationManager() - .uninitialize(InitializationAspect.MICROPHONE_CONNECT); - } + runIfChanged( + (config) => config.view.keyboard_shortcuts, + () => setKeyboardShortcutsFromConfig(this._api), + ); + runIfChanged( + (config) => config.remote_control, + () => setRemoteControlEntityFromConfig(this._api), + ); + runIfChanged( + (config) => config.automations, + () => setAutomationsFromConfig(this._api), + ); + runIfChanged( + (config) => [config.cameras, config.cameras_global], + () => { + this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS); + this._api.getCameraManager().destroy(); + }, + true, + ); + runIfChanged( + (config) => config.live.microphone.always_connected, + () => { + this._api + .getInitializationManager() + .uninitialize(InitializationAspect.MICROPHONE_CONNECT); + }, + true, + ); /* async */ this._initializeBackgroundAndUpdate(previousConfig); } diff --git a/src/card-controller/config/load-automations.ts b/src/card-controller/config/load-automations.ts index 5d5c35a7..1fe2ab4d 100644 --- a/src/card-controller/config/load-automations.ts +++ b/src/card-controller/config/load-automations.ts @@ -4,5 +4,5 @@ export const setAutomationsFromConfig = (api: CardConfigLoaderAPI): void => { api.getAutomationsManager().deleteAutomations(); api .getAutomationsManager() - .addAutomations(api.getConfigManager().getNonOverriddenConfig()?.automations ?? []); + .addAutomations(api.getConfigManager().getConfig()?.automations ?? []); }; diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index c06230f4..454a23d6 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ZodError, z } from 'zod'; import { AutomationsManager } from '../../../src/card-controller/automations-manager'; import { ConfigManager } from '../../../src/card-controller/config/config-manager'; +import { setRemoteControlEntityFromConfig } from '../../../src/card-controller/config/load-control-entities'; +import { setKeyboardShortcutsFromConfig } from '../../../src/card-controller/config/load-keyboard-shortcuts'; import { InitializationAspect } from '../../../src/card-controller/initialization-manager'; import { ConditionStateManager } from '../../../src/conditions/state-manager'; import { Automation } from '../../../src/config/schema/automations'; @@ -8,7 +11,6 @@ import { AdvancedCameraCardCondition } from '../../../src/config/schema/conditio import { advancedCameraCardConfigSchema } from '../../../src/config/schema/types'; import { createGeneralAction } from '../../../src/utils/action'; import { createCardAPI, createConfig, flushPromises } from '../../test-utils'; -import { ZodError, z } from 'zod'; /** * Create a ConfigManager test setup with real AutomationsManager and ConditionStateManager. @@ -679,6 +681,46 @@ describe('ConfigManager', () => { // The automation should still not execute because it was deleted by the override expect(executeActionsMock).not.toHaveBeenCalled(); }); + + it('should not reload user automations for unrelated override changes', async () => { + const { manager, stateManager, api, addAutomationsSpy, deleteAutomationsSpy } = + createConfigManagerTestSetup(); + + const executeActionsMock = vi.fn(); + vi.mocked(api.getActionsManager().executeActions).mockImplementation( + executeActionsMock, + ); + + const automation = { + conditions: [TEST_CONDITIONS.FULLSCREEN_ON], + actions: [createGeneralAction('screenshot')], + }; + const config = createConfig({ + automations: [automation], + overrides: [ + { + conditions: [TEST_CONDITIONS.FULLSCREEN_ON], + set: { + 'menu.buttons.microphone.enabled': false, + }, + }, + ], + }); + + manager.setConfig(config); + await flushPromises(); + + addAutomationsSpy.mockClear(); + deleteAutomationsSpy.mockClear(); + executeActionsMock.mockClear(); + + stateManager.setState({ fullscreen: true }); + await flushPromises(); + + expect(deleteAutomationsSpy).not.toHaveBeenCalled(); + expect(addAutomationsSpy).not.toHaveBeenCalled(); + expect(executeActionsMock).toHaveBeenCalledTimes(1); + }); }); describe('remote-control loader with overrides', () => { @@ -759,6 +801,73 @@ describe('ConfigManager', () => { ]), ); }); + + it('should not reload remote-control automations for unrelated override changes', async () => { + const { manager, stateManager, deleteAutomationsSpy } = + createConfigManagerTestSetup(); + + const config = createConfig({ + remote_control: { + entities: { camera: 'input_select.camera' }, + }, + overrides: [ + { + conditions: [TEST_CONDITIONS.FULLSCREEN_ON], + set: { + 'menu.buttons.microphone.enabled': false, + }, + }, + ], + }); + + manager.setConfig(config); + await flushPromises(); + + deleteAutomationsSpy.mockClear(); + + stateManager.setState({ fullscreen: true }); + await flushPromises(); + + expect(deleteAutomationsSpy).not.toHaveBeenCalledWith( + setRemoteControlEntityFromConfig, + ); + }); + }); + + describe('keyboard-shortcuts loader with overrides', () => { + it('should not reload keyboard-shortcut automations for unrelated override changes', async () => { + const { manager, stateManager, deleteAutomationsSpy } = + createConfigManagerTestSetup(); + + const config = createConfig({ + view: { + keyboard_shortcuts: { + enabled: true, + ptz_left: { key: 'ArrowLeft' }, + }, + }, + overrides: [ + { + conditions: [TEST_CONDITIONS.FULLSCREEN_ON], + set: { + 'menu.buttons.microphone.enabled': false, + }, + }, + ], + }); + + manager.setConfig(config); + await flushPromises(); + + deleteAutomationsSpy.mockClear(); + + stateManager.setState({ fullscreen: true }); + await flushPromises(); + + expect(deleteAutomationsSpy).not.toHaveBeenCalledWith( + setKeyboardShortcutsFromConfig, + ); + }); }); }); }); diff --git a/tests/card-controller/config/load-automations.test.ts b/tests/card-controller/config/load-automations.test.ts index 6fd7d6f5..68f5f0b0 100644 --- a/tests/card-controller/config/load-automations.test.ts +++ b/tests/card-controller/config/load-automations.test.ts @@ -24,7 +24,7 @@ describe('setAutomationsFromConfig', () => { }, ]; const api = createCardAPI(); - vi.mocked(api.getConfigManager().getNonOverriddenConfig).mockReturnValue( + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ automations: automations, }),