fix: Prevent microphone auto-disconnect during calls and restore the removed microphone connected condition (#2597)

- Closes: #2590
This commit is contained in:
Dermot Duffy
2026-07-22 16:12:31 -07:00
committed by GitHub
parent 8c36637092
commit 5a549c0326
14 changed files with 520 additions and 346 deletions
+110 -1
View File
@@ -1386,6 +1386,75 @@ describe('answer', () => {
});
});
describe('microphone usage', () => {
it('should mark the microphone in use for the duration of the call', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
expect(api.getMicrophoneManager().startUsing).toBeCalledTimes(1);
expect(api.getMicrophoneManager().stopUsing).not.toBeCalled();
manager.end();
expect(api.getMicrophoneManager().stopUsing).toBeCalledTimes(1);
});
it('should not mark the microphone in use when the start is aborted', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error());
expect(await new CallManager(api).start()).toBe(false);
expect(api.getMicrophoneManager().startUsing).not.toBeCalled();
});
it('should keep the microphone in use when a call supersedes another', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.garage',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true);
expect(api.getMicrophoneManager().startUsing).toBeCalledTimes(2);
expect(api.getMicrophoneManager().stopUsing).toBeCalledTimes(1);
});
it('should mark the microphone unused on uninitialization', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
manager.uninitialize();
expect(api.getMicrophoneManager().stopUsing).toBeCalledTimes(1);
});
it('should not mark the microphone unused when uninitializing without a call', () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
new CallManager(api).uninitialize();
expect(api.getMicrophoneManager().stopUsing).not.toBeCalled();
});
});
// Ringtone integration: started only for inbound + unanswered + a configured
// ringtone other than 'none'; stopped on end / uninitialize.
describe('ringtone', () => {
@@ -1610,7 +1679,7 @@ describe('session end during setState', () => {
});
});
describe('uninitialize during in-flight start', () => {
describe('state changes during in-flight start', () => {
it('should not install a session or ring when uninitialized mid-await', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
@@ -1660,6 +1729,46 @@ describe('uninitialize during in-flight start', () => {
expect(manager.isActive()).toBe(false);
});
it('should supersede a session installed by another start mid-await', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.garage',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
let resolveConnect: () => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve) => {
resolveConnect = resolve;
}),
);
const manager = new CallManager(api);
manager.initialize();
// Both requests read the (absent) session before either can install one.
const first = manager.start();
const second = manager.start({ cameraID: 'camera.garage' });
resolveConnect();
expect(await first).toBe(true);
expect(await second).toBe(true);
// The second request must end the first request's session rather than
// overwrite it, leaving exactly one live session and no stranded microphone
// marking.
expect(manager.getCall()?.cameraID).toBe('camera.garage');
expect(api.getMicrophoneManager().startUsing).toBeCalledTimes(2);
expect(api.getMicrophoneManager().stopUsing).toBeCalledTimes(1);
});
it('should suppress the microphone-failure notification when uninitialized mid-await', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
@@ -245,6 +245,159 @@ describe('MicrophoneManager', () => {
expect(api.getCardElementManager().update).toBeCalledTimes(1);
});
describe('should stay connected while in use', () => {
const disconnectSeconds = 10;
const createManagerWithDisconnectSeconds = (): MicrophoneManager => {
const api = createCardAPI();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: false,
disconnect_seconds: disconnectSeconds,
},
},
}),
);
return new MicrophoneManager(api);
};
it('should not automatically disconnect while a user is registered', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
manager.startUsing();
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeTruthy();
});
it('should not automatically disconnect when a user is registered before connection', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
manager.startUsing();
await manager.connect();
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeTruthy();
});
it('should not automatically disconnect on mute or unmute while in use', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
manager.startUsing();
await manager.unmute();
manager.mute();
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeTruthy();
});
it('should restart the countdown in full when use stops', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
manager.startUsing();
vi.advanceTimersByTime(disconnectSeconds * 1000);
manager.stopUsing();
vi.advanceTimersByTime(disconnectSeconds * 1000 - 1);
expect(manager.isConnected()).toBeTruthy();
vi.advanceTimersByTime(1);
expect(manager.isConnected()).toBeFalsy();
});
it('should become idle on a single stop no matter how often use was started', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
manager.startUsing();
manager.startUsing();
manager.stopUsing();
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeFalsy();
});
it('should restart the countdown when stopped without having been in use', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
manager.stopUsing();
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
expect(manager.isConnected()).toBeTruthy();
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
expect(manager.isConnected()).toBeFalsy();
});
it('should not reconnect after an explicit disconnect while in use', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
manager.startUsing();
manager.disconnect();
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeFalsy();
});
});
it('should not disconnect after being explicitly disconnected and reconnected', async () => {
vi.useFakeTimers();
const disconnectSeconds = 10;
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: false,
disconnect_seconds: disconnectSeconds,
},
},
}),
);
await manager.connect();
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
manager.disconnect();
await manager.connect();
// The countdown from the first connection must not survive the disconnect
// and cut the second one short.
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
expect(manager.isConnected()).toBeTruthy();
});
describe('should require initialization', async () => {
it('should require when configured and supported', async () => {
const api = createCardAPI();
@@ -44,4 +44,74 @@ describe('microphone condition', () => {
evaluator.evaluate({ microphone: createMicrophoneState({ muted: false }) }).result,
).toBeTruthy();
});
it('should match when connected is true', () => {
const evaluator = createConditionEvaluator(
{ condition: 'microphone' as const, connected: true },
createEvaluatorContext(),
);
expect(evaluator.evaluate({}).result).toBeFalsy();
expect(
evaluator.evaluate({ microphone: createMicrophoneState({ connected: true }) })
.result,
).toBeTruthy();
expect(
evaluator.evaluate({ microphone: createMicrophoneState({ connected: false }) })
.result,
).toBeFalsy();
});
it('should match when connected is false', () => {
const evaluator = createConditionEvaluator(
{ condition: 'microphone' as const, connected: false },
createEvaluatorContext(),
);
expect(evaluator.evaluate({}).result).toBeFalsy();
expect(
evaluator.evaluate({ microphone: createMicrophoneState({ connected: true }) })
.result,
).toBeFalsy();
expect(
evaluator.evaluate({ microphone: createMicrophoneState({ connected: false }) })
.result,
).toBeTruthy();
});
it('should require both connected and muted to match when both are given', () => {
const evaluator = createConditionEvaluator(
{ condition: 'microphone' as const, connected: true, muted: false },
createEvaluatorContext(),
);
expect(
evaluator.evaluate({
microphone: createMicrophoneState({ connected: true, muted: false }),
}).result,
).toBeTruthy();
expect(
evaluator.evaluate({
microphone: createMicrophoneState({ connected: true, muted: true }),
}).result,
).toBeFalsy();
expect(
evaluator.evaluate({
microphone: createMicrophoneState({ connected: false, muted: false }),
}).result,
).toBeFalsy();
});
it('should match any microphone state when neither parameter is given', () => {
const evaluator = createConditionEvaluator(
{ condition: 'microphone' as const },
createEvaluatorContext(),
);
expect(evaluator.evaluate({}).result).toBeTruthy();
expect(
evaluator.evaluate({ microphone: createMicrophoneState({ connected: true }) })
.result,
).toBeTruthy();
});
});
@@ -34,6 +34,72 @@ describe('MicrophoneTrigger', () => {
expect(callback).toHaveBeenCalledTimes(2);
});
it('should trigger on any connection change without a value', () => {
const { stateManager, callback } = create({ trigger: 'microphone' });
stateManager.setState({ microphone: createMicrophoneState({ connected: true }) });
stateManager.setState({ microphone: createMicrophoneState({ connected: false }) });
expect(callback).toHaveBeenCalledTimes(2);
});
it('should trigger only on changes to the given connected value', () => {
const { stateManager, callback } = create({
trigger: 'microphone',
connected: true,
});
stateManager.setState({ microphone: createMicrophoneState({ connected: true }) });
expect(callback).toHaveBeenCalledTimes(1);
stateManager.setState({ microphone: createMicrophoneState({ connected: false }) });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should not trigger on a mute change when only connected is given', () => {
const { stateManager, callback } = create({
trigger: 'microphone',
connected: true,
});
stateManager.setState({
microphone: createMicrophoneState({ connected: true, muted: true }),
});
expect(callback).toHaveBeenCalledTimes(1);
stateManager.setState({
microphone: createMicrophoneState({ connected: true, muted: false }),
});
expect(callback).toHaveBeenCalledTimes(1);
});
it('should not trigger on a connection change when only muted is given', () => {
const { stateManager, callback } = create({ trigger: 'microphone', muted: true });
stateManager.setState({
microphone: createMicrophoneState({ connected: false, muted: true }),
});
expect(callback).toHaveBeenCalledTimes(1);
stateManager.setState({
microphone: createMicrophoneState({ connected: true, muted: true }),
});
expect(callback).toHaveBeenCalledTimes(1);
});
it('should require both connected and muted to match when both are given', () => {
const { stateManager, callback } = create({
trigger: 'microphone',
connected: true,
muted: false,
});
stateManager.setState({
microphone: createMicrophoneState({ connected: true, muted: true }),
});
expect(callback).not.toHaveBeenCalled();
stateManager.setState({
microphone: createMicrophoneState({ connected: true, muted: false }),
});
expect(callback).toHaveBeenCalledTimes(1);
});
it('should trigger only on changes to the given mute value', () => {
const { stateManager, callback } = create({ trigger: 'microphone', muted: true });
stateManager.setState({ microphone: createMicrophoneState({ muted: true }) });
+34 -265
View File
@@ -3981,271 +3981,6 @@ describe('should handle version specific upgrades', () => {
});
});
describe('microphone.connected -> call condition', () => {
it('should rewrite connected:true into an active-phase trigger in an automation', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [{ condition: 'microphone', connected: true }],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'live',
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0]).toEqual(
expect.objectContaining({
triggers: [{ trigger: 'call', to: ['ringing', 'answered'] }],
}),
);
postUpgradeChecks(config);
});
it('should rewrite connected:false into an idle-phase trigger', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [{ condition: 'microphone', connected: false }],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'live',
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0]).toEqual(
expect.objectContaining({ triggers: [{ trigger: 'call', to: 'idle' }] }),
);
postUpgradeChecks(config);
});
it('should not convert a microphone.muted only condition to call', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [{ condition: 'microphone', muted: true }],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'live',
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0]).toEqual(
expect.objectContaining({
triggers: [{ trigger: 'microphone', muted: true }],
}),
);
postUpgradeChecks(config);
});
it('should split a condition with both connected and muted into an AND condition', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [{ condition: 'microphone', connected: true, muted: false }],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'live',
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0].conditions).toEqual([
{
condition: 'and',
conditions: [
{ condition: 'call', call: ['ringing', 'answered'] },
{ condition: 'microphone', muted: false },
],
},
]);
postUpgradeChecks(config);
});
it('should migrate a microphone.connected nested under or/and/not', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [
{
condition: 'or',
conditions: [
{
condition: 'and',
conditions: [
{
condition: 'not',
conditions: [{ condition: 'microphone', connected: true }],
},
],
},
],
},
],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'live',
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0].conditions).toEqual([
{
condition: 'or',
conditions: [
{
condition: 'and',
conditions: [
{
condition: 'not',
conditions: [{ condition: 'call', call: ['ringing', 'answered'] }],
},
],
},
],
},
]);
postUpgradeChecks(config);
});
it('should migrate conditions on elements and overrides', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
elements: [
{
type: 'custom:advanced-camera-card-conditional',
conditions: [{ condition: 'microphone', connected: true }],
elements: [{ type: 'icon', icon: 'mdi:phone' }],
},
],
overrides: [
{
conditions: [{ condition: 'microphone', connected: false }],
merge: {},
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.elements[0].conditions).toEqual([
{ condition: 'call', call: ['ringing', 'answered'] },
]);
expect(config.overrides[0].conditions).toEqual([
{ condition: 'call', call: 'idle' },
]);
postUpgradeChecks(config);
});
it('should keep a disabled condition disabled through to the trigger', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [{ condition: 'microphone', connected: true, enabled: false }],
actions: [
{ action: 'fire-dom-event', advanced_camera_card_action: 'live' },
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0]).toEqual(
expect.objectContaining({
triggers: [{ trigger: 'call', to: ['ringing', 'answered'], enabled: false }],
}),
);
postUpgradeChecks(config);
});
it('should keep a disabled condition disabled when split into an AND', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
overrides: [
{
conditions: [
{
condition: 'microphone',
connected: true,
muted: false,
enabled: false,
},
],
merge: {},
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.overrides[0].conditions).toEqual([
{
condition: 'and',
conditions: [
{ condition: 'call', call: ['ringing', 'answered'] },
{ condition: 'microphone', muted: false },
],
enabled: false,
},
]);
postUpgradeChecks(config);
});
it('should be idempotent', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [{ condition: 'microphone', connected: true }],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'live',
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
// Running upgradeConfig again should not change anything.
expect(upgradeConfig(config)).toBeFalsy();
expect(config.automations[0]).toEqual(
expect.objectContaining({
triggers: [{ trigger: 'call', to: ['ringing', 'answered'] }],
}),
);
postUpgradeChecks(config);
});
});
describe('automation conditions -> triggers', () => {
it('should promote a state condition to a trigger and keep its other fields', () => {
const config = {
@@ -4287,6 +4022,40 @@ describe('should handle version specific upgrades', () => {
postUpgradeChecks(config);
});
it('should promote a call condition to a trigger arriving at its phases', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [
{
condition: 'call',
call: ['ringing', 'answered'],
enabled: false,
},
],
actions: [
{ action: 'fire-dom-event', advanced_camera_card_action: 'live' },
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0]).toEqual(
expect.objectContaining({
triggers: [
{
trigger: 'call',
to: ['ringing', 'answered'],
enabled: false,
},
],
}),
);
postUpgradeChecks(config);
});
it('should flatten a composite condition into trigger leaves and keep the composite', () => {
const config = {
type: 'custom:advanced-camera-card',