fix: Use auto_unmute as input to stream selection for HA (#2562)

- Closes #2479
This commit is contained in:
Dermot Duffy
2026-07-03 08:19:11 -07:00
committed by GitHub
parent 6a6d65d5c5
commit 3494706225
7 changed files with 428 additions and 61 deletions
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { isAudioIntendedOnLoad } from '../../../src/components-lib/live/audio-intent';
describe('isAudioIntendedOnLoad', () => {
it('should return false when no conditions are set', () => {
expect(isAudioIntendedOnLoad([])).toBe(false);
});
it('should return false for the default microphone and call conditions', () => {
expect(isAudioIntendedOnLoad(['microphone', 'call'])).toBe(false);
});
it('should return true when selected is a condition', () => {
expect(isAudioIntendedOnLoad(['selected'])).toBe(true);
});
it('should return true when visible is a condition', () => {
expect(isAudioIntendedOnLoad(['visible'])).toBe(true);
});
it('should return true when a positive condition is combined with others', () => {
expect(isAudioIntendedOnLoad(['call', 'visible'])).toBe(true);
});
});
@@ -0,0 +1,211 @@
import { describe, expect, it, vi } from 'vitest';
import {
HA_CAMERA_STREAM_MUTE_CHANGE_EVENT,
HAStreamMuteController,
} from '../../../src/components-lib/live/ha-stream-mute-controller';
import { createLitElement } from '../../test-utils';
// @vitest-environment jsdom
interface ControllerState {
cameraEntityID: string | null;
preferAudioStream: boolean;
}
const createController = (state: ControllerState) => {
const host = createLitElement();
const controller = new HAStreamMuteController(host, {
getCameraEntityID: () => state.cameraEntityID,
getPreferAudioStream: () => state.preferAudioStream,
});
return { host, controller };
};
const dispatchMuteChange = (host: HTMLElement, muted: boolean): void => {
host.dispatchEvent(
new CustomEvent(HA_CAMERA_STREAM_MUTE_CHANGE_EVENT, { detail: { muted } }),
);
};
describe('HAStreamMuteController', () => {
it('should add itself as a controller to the host', () => {
const host = createLitElement();
const controller = new HAStreamMuteController(host, {
getCameraEntityID: () => 'camera.test',
getPreferAudioStream: () => false,
});
expect(host.addController).toHaveBeenCalledWith(controller);
});
it('should default both the intended and output mute to muted', () => {
const { controller } = createController({
cameraEntityID: 'camera.test',
preferAudioStream: false,
});
expect(controller.getIntendedMute()).toBe(true);
expect(controller.getOutputMute()).toBe(true);
});
describe('seeding on a camera change', () => {
it('should seed the intended mute as muted when audio is not intended', () => {
const { controller } = createController({
cameraEntityID: 'camera.test',
preferAudioStream: false,
});
controller.hostUpdate();
expect(controller.getIntendedMute()).toBe(true);
});
it('should seed the intended mute as unmuted when audio is intended', () => {
const { controller } = createController({
cameraEntityID: 'camera.test',
preferAudioStream: true,
});
controller.hostUpdate();
expect(controller.getIntendedMute()).toBe(false);
});
});
describe('on a visible-leaf mute change', () => {
it('should mirror the output mute', () => {
const { host, controller } = createController({
cameraEntityID: 'camera.test',
preferAudioStream: false,
});
controller.hostConnected();
controller.hostUpdate();
dispatchMuteChange(host, false);
expect(controller.getOutputMute()).toBe(false);
dispatchMuteChange(host, true);
expect(controller.getOutputMute()).toBe(true);
});
it('should flip the intended mute to unmuted on the first unmute', () => {
const { host, controller } = createController({
cameraEntityID: 'camera.test',
preferAudioStream: false,
});
controller.hostConnected();
controller.hostUpdate();
expect(controller.getIntendedMute()).toBe(true);
dispatchMuteChange(host, false);
expect(controller.getIntendedMute()).toBe(false);
});
it('should not flip the intended mute back when muted again', () => {
const { host, controller } = createController({
cameraEntityID: 'camera.test',
preferAudioStream: false,
});
controller.hostConnected();
controller.hostUpdate();
dispatchMuteChange(host, false);
dispatchMuteChange(host, true);
expect(controller.getIntendedMute()).toBe(false);
expect(controller.getOutputMute()).toBe(true);
});
it('should request a host update only when the state changes', () => {
const { host, controller } = createController({
cameraEntityID: 'camera.test',
preferAudioStream: false,
});
controller.hostConnected();
controller.hostUpdate();
vi.mocked(host.requestUpdate).mockClear();
// Already muted: no change.
dispatchMuteChange(host, true);
expect(host.requestUpdate).not.toHaveBeenCalled();
// Unmute: change.
dispatchMuteChange(host, false);
expect(host.requestUpdate).toHaveBeenCalledTimes(1);
});
it('should ignore malformed mute-change events', () => {
const { host, controller } = createController({
cameraEntityID: 'camera.test',
preferAudioStream: false,
});
controller.hostConnected();
controller.hostUpdate();
vi.mocked(host.requestUpdate).mockClear();
const eventName = HA_CAMERA_STREAM_MUTE_CHANGE_EVENT;
host.dispatchEvent(new Event(eventName)); // not a CustomEvent
host.dispatchEvent(new CustomEvent(eventName)); // detail is null
host.dispatchEvent(new CustomEvent(eventName, { detail: 'x' })); // detail not an object
host.dispatchEvent(new CustomEvent(eventName, { detail: {} })); // no `muted`
host.dispatchEvent(new CustomEvent(eventName, { detail: { muted: 1 } })); // `muted` not boolean
expect(host.requestUpdate).not.toHaveBeenCalled();
expect(controller.getIntendedMute()).toBe(true);
expect(controller.getOutputMute()).toBe(true);
});
});
describe('resetting on a camera change', () => {
it('should reseed the intended mute and clear the output mute on a new camera', () => {
const state: ControllerState = {
cameraEntityID: 'camera.one',
preferAudioStream: false,
};
const { host, controller } = createController(state);
controller.hostConnected();
controller.hostUpdate();
dispatchMuteChange(host, false);
expect(controller.getIntendedMute()).toBe(false);
expect(controller.getOutputMute()).toBe(false);
state.cameraEntityID = 'camera.two';
controller.hostUpdate();
expect(controller.getIntendedMute()).toBe(true);
expect(controller.getOutputMute()).toBe(true);
});
it('should not reset when only the audio intent changes', () => {
const state: ControllerState = {
cameraEntityID: 'camera.one',
preferAudioStream: false,
};
const { host, controller } = createController(state);
controller.hostConnected();
controller.hostUpdate();
dispatchMuteChange(host, false);
expect(controller.getIntendedMute()).toBe(false);
state.preferAudioStream = true;
controller.hostUpdate();
// Same camera: the user's unmute is preserved, not reseeded.
expect(controller.getIntendedMute()).toBe(false);
});
});
describe('lifecycle', () => {
it('should stop responding after disconnect', () => {
const { host, controller } = createController({
cameraEntityID: 'camera.test',
preferAudioStream: false,
});
controller.hostConnected();
controller.hostUpdate();
controller.hostDisconnected();
dispatchMuteChange(host, false);
expect(controller.getIntendedMute()).toBe(true);
expect(controller.getOutputMute()).toBe(true);
});
});
});