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 <dermot.duffy@gmail.com>
This commit is contained in:
Felipe Santos
2026-03-08 20:36:47 -07:00
committed by GitHub
co-authored by dermotduffy
parent 4fceade37c
commit ee23cb2b9d
4 changed files with 164 additions and 28 deletions
+47 -20
View File
@@ -138,32 +138,59 @@ export class ConfigManager {
setFoldersFromConfig(this._api); setFoldersFromConfig(this._api);
this._api.getStyleManager().updateFromConfig(); this._api.getStyleManager().updateFromConfig();
// Ensure features that register automations or other side-effects from // Only re-run side-effect callbacks when their relevant config section
// configuration are updated when overrides change (e.g. remote_control). // changed. As an example of why: Automation loaders delete then recreate
// Re-run loaders that may add/remove automations based on the current // automations, which destroys associated ConditionsManagers. If a condition
// effective configuration. // transition (e.g. microphone connect) triggers both a user automation and
setKeyboardShortcutsFromConfig(this._api); // an unrelated override, an unconditional reload would delete the
setRemoteControlEntityFromConfig(this._api); // automation mid-transition — the freshly created replacement has no prior
setAutomationsFromConfig(this._api); // state, treats the current condition as its baseline, and never fires the
// action.
const runIfChanged = <T>(
extract: (config: AdvancedCameraCardConfig) => T,
callback: () => void,
skipFirst?: boolean,
): void => {
if (!previousConfig) {
if (!skipFirst) {
callback();
}
return;
}
if (!isEqual(extract(previousConfig), extract(overriddenConfig))) {
callback();
}
};
if ( runIfChanged(
previousConfig && (config) => config.view.keyboard_shortcuts,
(!isEqual(previousConfig?.cameras, this._overriddenConfig?.cameras) || () => setKeyboardShortcutsFromConfig(this._api),
!isEqual(previousConfig?.cameras_global, this._overriddenConfig?.cameras_global)) );
) { 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.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
this._api.getCameraManager().destroy(); this._api.getCameraManager().destroy();
} },
true,
if ( );
previousConfig && runIfChanged(
previousConfig?.live.microphone.always_connected !== (config) => config.live.microphone.always_connected,
this._overriddenConfig?.live.microphone.always_connected () => {
) {
this._api this._api
.getInitializationManager() .getInitializationManager()
.uninitialize(InitializationAspect.MICROPHONE_CONNECT); .uninitialize(InitializationAspect.MICROPHONE_CONNECT);
} },
true,
);
/* async */ this._initializeBackgroundAndUpdate(previousConfig); /* async */ this._initializeBackgroundAndUpdate(previousConfig);
} }
@@ -4,5 +4,5 @@ export const setAutomationsFromConfig = (api: CardConfigLoaderAPI): void => {
api.getAutomationsManager().deleteAutomations(); api.getAutomationsManager().deleteAutomations();
api api
.getAutomationsManager() .getAutomationsManager()
.addAutomations(api.getConfigManager().getNonOverriddenConfig()?.automations ?? []); .addAutomations(api.getConfigManager().getConfig()?.automations ?? []);
}; };
@@ -1,6 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ZodError, z } from 'zod';
import { AutomationsManager } from '../../../src/card-controller/automations-manager'; import { AutomationsManager } from '../../../src/card-controller/automations-manager';
import { ConfigManager } from '../../../src/card-controller/config/config-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 { InitializationAspect } from '../../../src/card-controller/initialization-manager';
import { ConditionStateManager } from '../../../src/conditions/state-manager'; import { ConditionStateManager } from '../../../src/conditions/state-manager';
import { Automation } from '../../../src/config/schema/automations'; 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 { advancedCameraCardConfigSchema } from '../../../src/config/schema/types';
import { createGeneralAction } from '../../../src/utils/action'; import { createGeneralAction } from '../../../src/utils/action';
import { createCardAPI, createConfig, flushPromises } from '../../test-utils'; import { createCardAPI, createConfig, flushPromises } from '../../test-utils';
import { ZodError, z } from 'zod';
/** /**
* Create a ConfigManager test setup with real AutomationsManager and ConditionStateManager. * 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 // The automation should still not execute because it was deleted by the override
expect(executeActionsMock).not.toHaveBeenCalled(); 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', () => { 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,
);
});
}); });
}); });
}); });
@@ -24,7 +24,7 @@ describe('setAutomationsFromConfig', () => {
}, },
]; ];
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getConfigManager().getNonOverriddenConfig).mockReturnValue( vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({ createConfig({
automations: automations, automations: automations,
}), }),