fix: Release the microphone to the browser when a call ends (#2685)

The microphone is connected when a call needs it and released the moment
that
call ends, so the browser stops reporting it as in use at hangup rather
than
`disconnect_seconds` later.

Existing configurations are migrated automatically by the visual editor.

 - Closes: #2681 

BREAKING CHANGE: `live.microphone.disconnect_seconds` is removed. The
microphone is released when a call ends, so there is no idle countdown
to
configure. Use `live.microphone.always_connected` to hold it open
instead.

BREAKING CHANGE: The `microphone_connect` and `microphone_disconnect`
actions
are removed. The card owns the microphone lifecycle; `microphone_mute`
and
`microphone_unmute` remain.

BREAKING CHANGE: `call` is removed from `live.microphone.auto_mute`,
whose
default is now `[]`. The microphone is muted when a call ends regardless
of
this option.

BREAKING CHANGE: `microphone_unmute` has no effect outside a call.
Nothing
carries the audio at any other time, so the request is ignored rather
than
opening the microphone.
This commit is contained in:
Dermot Duffy
2026-08-14 16:17:31 -07:00
committed by GitHub
parent 65bc52d18e
commit 85d6811761
33 changed files with 1480 additions and 572 deletions
+446
View File
@@ -3749,6 +3749,452 @@ describe('should handle version specific upgrades', () => {
});
describe('v8.0.0+', () => {
describe('should remove the retired microphone options', () => {
it('should delete disconnect_seconds', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { microphone: { disconnect_seconds: 120, always_connected: true } },
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: { microphone: { always_connected: true } },
});
postUpgradeChecks(config);
});
it('should delete disconnect_seconds in overrides', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
overrides: [
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: true }],
merge: { live: { microphone: { disconnect_seconds: 120 } } },
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.overrides[0].merge).toEqual({ live: { microphone: {} } });
postUpgradeChecks(config);
});
it('should strip call from the microphone auto_mute', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { microphone: { auto_mute: ['hidden', 'call'] } },
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: { microphone: { auto_mute: ['hidden'] } },
});
postUpgradeChecks(config);
});
it('should empty the microphone auto_mute when only call is listed', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { microphone: { auto_mute: ['call'], always_connected: true } },
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: { microphone: { auto_mute: [], always_connected: true } },
});
postUpgradeChecks(config);
});
it('should empty an overridden microphone auto_mute rather than delete it', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { microphone: { auto_mute: ['hidden' as const] } },
overrides: [
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: true }],
set: { 'live.microphone.auto_mute': ['call'] },
},
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: false }],
merge: { live: { microphone: { auto_mute: ['call'] } } },
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
// Deleting the key would restore the base `['hidden']` for anyone the
// override applies to, rather than leaving them with nothing.
expect(config.overrides[0].set).toEqual({
live: { microphone: { auto_mute: [] } },
});
expect(config.overrides[1].merge).toEqual({
live: { microphone: { auto_mute: [] } },
});
postUpgradeChecks(config);
});
it('should leave a microphone auto_mute without call alone', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { microphone: { auto_mute: ['hidden'] } },
};
expect(upgradeConfig(config)).toBeFalsy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: { microphone: { auto_mute: ['hidden'] } },
});
});
it('should not strip call from the live auto_mute', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { auto_mute: ['hidden', 'call'] },
};
expect(upgradeConfig(config)).toBeFalsy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: { auto_mute: ['hidden', 'call'] },
});
});
});
describe('should remove the retired microphone actions', () => {
it('should delete a singular tap_action', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
elements: [
{
type: 'custom:advanced-camera-card-menu-icon' as const,
icon: 'mdi:microphone',
tap_action: {
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_connect' as const,
},
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.elements[0]).toEqual({
type: 'custom:advanced-camera-card-menu-icon',
icon: 'mdi:microphone',
});
postUpgradeChecks(config);
});
it('should splice an action array and keep the order of the rest', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
elements: [
{
type: 'custom:advanced-camera-card-menu-icon' as const,
icon: 'mdi:microphone',
tap_action: [
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_connect' as const,
},
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_unmute' as const,
},
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_disconnect' as const,
},
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_mute' as const,
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.elements[0]['tap_action']).toEqual([
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_unmute',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_mute',
},
]);
postUpgradeChecks(config);
});
it('should delete the start and end tap actions of a momentary button', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
elements: [
{
type: 'custom:advanced-camera-card-menu-icon' as const,
icon: 'mdi:microphone',
start_tap_action: {
action: 'fire-dom-event' as const,
advanced_camera_card_action: 'microphone_connect' as const,
},
end_tap_action: {
action: 'fire-dom-event' as const,
advanced_camera_card_action: 'microphone_disconnect' as const,
},
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.elements[0]).toEqual({
type: 'custom:advanced-camera-card-menu-icon',
icon: 'mdi:microphone',
});
postUpgradeChecks(config);
});
it('should remove the actions from an override', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
overrides: [
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: true }],
merge: {
'menu.buttons.microphone': {
tap_action: {
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_connect' as const,
},
},
},
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.overrides[0].merge).toEqual({
'menu.buttons.microphone': {},
});
postUpgradeChecks(config);
});
it('should keep an automation that retains other actions', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
automations: [
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: true }],
actions: [
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_connect' as const,
},
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_unmute' as const,
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0]['actions']).toEqual([
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_unmute',
},
]);
postUpgradeChecks(config);
});
it('should empty the actions of an automation left with none', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
automations: [
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: true }],
actions: [
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_connect' as const,
},
],
},
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: false }],
actions: [
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_mute' as const,
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations).toHaveLength(2);
expect(config.automations[0]['actions']).toEqual([]);
expect(config.automations[1]['actions']).toEqual([
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_mute',
},
]);
postUpgradeChecks(config);
});
it('should keep the required then of an if action that empties', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
automations: [
{
triggers: [{ trigger: 'camera' as const }],
actions: [
{
if: [{ condition: 'fullscreen' as const, fullscreen: true }],
then: [
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_disconnect' as const,
},
],
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
// `then` is required by the schema, so it must survive as an empty
// array rather than be deleted.
expect(config.automations[0]['actions']).toEqual([
{ if: [{ condition: 'fullscreen', fullscreen: true }], then: [] },
]);
postUpgradeChecks(config);
});
it('should leave an unrelated action payload alone', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
elements: [
{
type: 'custom:advanced-camera-card-menu-icon' as const,
icon: 'mdi:microphone',
tap_action: {
action: 'perform-action' as const,
perform_action: 'input_boolean.turn_on',
data: {
advanced_camera_card_action: 'microphone_connect',
},
},
},
],
};
expect(upgradeConfig(config)).toBeFalsy();
expect(config.elements[0]['tap_action']).toEqual({
action: 'perform-action',
perform_action: 'input_boolean.turn_on',
data: {
advanced_camera_card_action: 'microphone_connect',
},
});
});
it('should leave an action-shaped service data payload alone', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
elements: [
{
type: 'custom:advanced-camera-card-menu-icon' as const,
icon: 'mdi:microphone',
tap_action: {
action: 'perform-action' as const,
perform_action: 'script.relay',
data: {
payload: {
action: 'fire-dom-event',
advanced_camera_card_action: 'microphone_connect',
},
},
},
},
],
};
expect(upgradeConfig(config)).toBeFalsy();
// `data` is opaque to the card: whatever it holds is the user's to
// send on, even when it looks like an action.
expect(config.elements[0]['tap_action']['data']).toEqual({
payload: {
action: 'fire-dom-event',
advanced_camera_card_action: 'microphone_connect',
},
});
});
it('should leave the surviving microphone actions alone', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
elements: [
{
type: 'custom:advanced-camera-card-menu-icon' as const,
icon: 'mdi:microphone',
tap_action: {
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_mute' as const,
},
},
],
};
expect(upgradeConfig(config)).toBeFalsy();
});
});
describe('live.controls.thumbnails.media_type -> cameras_global.media.type', () => {
it.each([['events' as const], ['recordings' as const]])(
'should handle %s',
+1 -10
View File
@@ -147,9 +147,8 @@ describe('config defaults', () => {
lazy_unload: [],
microphone: {
always_connected: false,
auto_mute: ['call'],
auto_mute: [],
auto_unmute: [],
disconnect_seconds: 90,
mute_after_microphone_mute_seconds: 60,
},
preload: false,
@@ -1111,14 +1110,6 @@ describe('config defaults', () => {
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'menu_toggle',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_connect',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_disconnect',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_mute',