fix: Prevent microphone auto-disconnect during calls and restore the removed microphone connected condition (#2597)
- Closes: #2590
This commit is contained in:
@@ -397,25 +397,45 @@ triggers:
|
||||
|
||||
## `microphone`
|
||||
|
||||
Matches the microphone state. As a **condition**, true while the mute state
|
||||
matches; as a **trigger**, fires when it becomes a match.
|
||||
Matches the microphone state. As a **condition**, true while every parameter
|
||||
given matches; as a **trigger**, fires when it becomes a match.
|
||||
|
||||
```yaml
|
||||
# As a condition:
|
||||
conditions:
|
||||
- condition: microphone
|
||||
connected: true
|
||||
muted: true
|
||||
# As a trigger:
|
||||
triggers:
|
||||
- trigger: microphone
|
||||
connected: true
|
||||
muted: true
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------- |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `condition` / `trigger` | Must be `microphone`. |
|
||||
| `connected` | If `true` or `false`, matches when the card does or does not hold an open microphone stream from the browser. See the note below. |
|
||||
| `muted` | If `true` or `false`, matches when the microphone is muted or unmuted respectively. |
|
||||
|
||||
> [!NOTE]
|
||||
> The `connected` parameter is about **browser microphone access**, not about
|
||||
> calls. It is `true` while the card holds an open microphone stream from the
|
||||
> browser -- the same state that lights your browser's "microphone in use"
|
||||
> indicator (e.g. useful for showing privacy indicators).
|
||||
>
|
||||
> A [two-way audio](../usage/2-way-audio.md) call opens the microphone, but so do
|
||||
> several things that involve no call at all: setting
|
||||
> [`live.microphone.always_connected`](live.md?id=microphone), or a
|
||||
> [`microphone_connect`](actions/custom/README.md?id=microphone_connect) or
|
||||
> [`microphone_unmute`](actions/custom/README.md?id=microphone_unmute) action. It
|
||||
> closes again after
|
||||
> [`live.microphone.disconnect_seconds`](live.md?id=microphone) of disuse, or on
|
||||
> a `microphone_disconnect` action.
|
||||
>
|
||||
> To match a call in progress, use [`call`](#call) instead.
|
||||
|
||||
## `not`
|
||||
|
||||
_Condition only._
|
||||
@@ -777,6 +797,7 @@ conditions:
|
||||
- condition: media_loaded
|
||||
media_loaded: true
|
||||
- condition: microphone
|
||||
connected: true
|
||||
muted: true
|
||||
- not:
|
||||
- condition: fullscreen
|
||||
@@ -854,6 +875,7 @@ triggers:
|
||||
- trigger: media_loaded
|
||||
media_loaded: true
|
||||
- trigger: microphone
|
||||
connected: true
|
||||
muted: true
|
||||
- trigger: numeric_state
|
||||
entity_id: sensor.office_temperature
|
||||
|
||||
@@ -226,7 +226,7 @@ live:
|
||||
| `always_connected` | `false` | Whether or not to keep the microphone stream continually connected while the card is running, or only connect it when first needed (default) -- typically when a [two-way audio](../usage/2-way-audio.md) call is started. In the latter case there'll be a connection reset the first time the microphone connects -- using this option can avoid that reset. |
|
||||
| `auto_mute` | `[call]` | A list of conditions in which the microphone is muted. `unselected` will automatically mute the microphone when a camera is unselected in the carousel or grid. `hidden` will automatically mute the microphone when the card becomes hidden (e.g. browser/tab change). `call` will automatically mute the microphone when an answered [two-way audio](../usage/2-way-audio.md) call ends. Use an empty list (`[]`) to never automatically mute the microphone via these conditions. |
|
||||
| `auto_unmute` | `[]` | A list of conditions in which the microphone is unmuted. `selected` will automatically unmute the microphone when a camera is selected in the carousel or grid. `visible` will automatically unmute when the card becomes visible. `call` will automatically unmute the microphone when a [two-way audio](../usage/2-way-audio.md) call is started (or answered for inbound calls). By default this list is empty, so the microphone stays muted even after answering (push-to-talk) -- tap the microphone button in the call overlay to talk. The microphone is still connected when the call starts (just left muted), so the browser may prompt for microphone permission at that point. |
|
||||
| `disconnect_seconds` | `90` | The number of seconds after microphone usage to disconnect the microphone from the stream. `0` implies never. Not relevant if `always_connected` is `true`. |
|
||||
| `disconnect_seconds` | `90` | The number of seconds after microphone usage to disconnect the microphone from the stream. `0` implies never. Not relevant if `always_connected` is `true`. The countdown is suspended for the duration of a [two-way audio](../usage/2-way-audio.md) call, and restarts in full when the call ends. |
|
||||
| `mute_after_microphone_mute_seconds` | `60` | The number of seconds after the microphone mutes to automatically mute the inbound audio when `live.auto_mute` includes `microphone`. |
|
||||
|
||||
See [Using 2-way audio](../usage/2-way-audio.md) for more information about the very particular requirements that must be followed for 2-way audio to work.
|
||||
|
||||
@@ -89,7 +89,7 @@ export class CallManager {
|
||||
// the call runs on the parent camera itself.
|
||||
const callCameraID = targetID === parentID ? undefined : targetID;
|
||||
|
||||
const existingCall = this._call;
|
||||
let existingCall = this._call;
|
||||
if (
|
||||
existingCall &&
|
||||
existingCall.cameraID === parentID &&
|
||||
@@ -120,6 +120,13 @@ export class CallManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-read: another `start()` may have installed a session while the
|
||||
// microphone connect was in flight. Acting on the reading from before the
|
||||
// await would skip the supersede handling below, leaving that session's
|
||||
// microphone marking and view state stranded with nothing able to undo
|
||||
// them.
|
||||
existingCall = this._call;
|
||||
|
||||
// Store the previous view so it can be restored later. A call superseding
|
||||
// another inherits the earlier call's previous view -- the user never left
|
||||
// the call. `queryResults` are dropped (re-fetched fresh on restore).
|
||||
@@ -152,6 +159,10 @@ export class CallManager {
|
||||
answered,
|
||||
};
|
||||
|
||||
// The microphone's own idle timeout knows nothing about calls, so without
|
||||
// this the tracks would be stopped mid-conversation.
|
||||
this._api.getMicrophoneManager().startUsing();
|
||||
|
||||
this._api.getViewManager().setViewByParameters({
|
||||
...(needsNavigation && {
|
||||
params: { view: 'live', camera: parentID },
|
||||
@@ -250,6 +261,7 @@ export class CallManager {
|
||||
this._unansweredTimer.stop();
|
||||
if (this._call) {
|
||||
this._call = null;
|
||||
this._api.getMicrophoneManager().stopUsing();
|
||||
this._api.getConditionStateManager().setState({ call: 'idle' });
|
||||
}
|
||||
this._api
|
||||
@@ -279,6 +291,10 @@ export class CallManager {
|
||||
// and recurse.
|
||||
this._call = null;
|
||||
|
||||
// The call no longer needs the microphone, so the normal
|
||||
// `disconnect_seconds` countdown restarts from here.
|
||||
this._api.getMicrophoneManager().stopUsing();
|
||||
|
||||
const viewManager = this._api.getViewManager();
|
||||
|
||||
// Navigate back only on an explicit end, and only when the call actually
|
||||
|
||||
@@ -25,6 +25,10 @@ export class MicrophoneManager {
|
||||
// it's created it will have the right mute status.
|
||||
private _desireMute = true;
|
||||
|
||||
// Whether something is actively using the microphone, and so when the
|
||||
// connection can safely be closed.
|
||||
private _inUse = false;
|
||||
|
||||
constructor(api: CardMicrophoneAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
@@ -73,12 +77,28 @@ export class MicrophoneManager {
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this._timer.stop();
|
||||
this._stream?.getTracks().forEach((track) => track.stop());
|
||||
|
||||
this._stream = undefined;
|
||||
this._setState();
|
||||
}
|
||||
|
||||
// Marks the microphone as in use (e.g. by an in-progress call). It stays
|
||||
// connected regardless of `disconnect_seconds` until `stopUsing`, since
|
||||
// stopping the tracks under a user would silently cut their audio.
|
||||
public startUsing(): void {
|
||||
this._inUse = true;
|
||||
this._timer.stop();
|
||||
}
|
||||
|
||||
// Marks the microphone as no longer in use. It is idle again, so the
|
||||
// disconnect countdown restarts from the full `disconnect_seconds`.
|
||||
public stopUsing(): void {
|
||||
this._inUse = false;
|
||||
this._startDisconnectTimer();
|
||||
}
|
||||
|
||||
public getStream(): MediaStream | undefined {
|
||||
return this._stream ?? undefined;
|
||||
}
|
||||
@@ -130,7 +150,7 @@ export class MicrophoneManager {
|
||||
private _startDisconnectTimer(): void {
|
||||
const microphoneConfig = this._api.getConfigManager().getConfig()?.live.microphone;
|
||||
|
||||
if (microphoneConfig?.always_connected) {
|
||||
if (microphoneConfig?.always_connected || this._inUse) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,11 @@ export class MicrophoneConditionEvaluator implements ConditionEvaluator {
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return {
|
||||
result: newState?.microphone?.muted === this._condition.muted,
|
||||
result:
|
||||
(this._condition.connected === undefined ||
|
||||
newState?.microphone?.connected === this._condition.connected) &&
|
||||
(this._condition.muted === undefined ||
|
||||
newState?.microphone?.muted === this._condition.muted),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,23 @@ import type { ConditionState } from '../../conditions/types';
|
||||
import { ConditionStateTriggerBase } from './condition-state-base';
|
||||
import type { TriggerOfType } from './types';
|
||||
|
||||
// Triggers when the microphone mute state changes: to the given value if `muted`
|
||||
// is set, or on any change if it is omitted.
|
||||
// Triggers when the microphone connection or mute state changes: to the given
|
||||
// values if `connected` / `muted` are set, or on any change if both are
|
||||
// omitted.
|
||||
export class MicrophoneTrigger extends ConditionStateTriggerBase<
|
||||
TriggerOfType<'microphone'>
|
||||
> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.microphone?.muted;
|
||||
const unconstrained =
|
||||
this._trigger.connected === undefined && this._trigger.muted === undefined;
|
||||
|
||||
return {
|
||||
...((unconstrained || this._trigger.connected !== undefined) && {
|
||||
connected: state.microphone?.connected,
|
||||
}),
|
||||
...((unconstrained || this._trigger.muted !== undefined) && {
|
||||
muted: state.microphone?.muted,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1428,56 +1428,6 @@ const frigateCardToAdvancedCameraCardTransform = (
|
||||
return modified;
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate a `condition: microphone` condition with the (removed) `connected`
|
||||
* field into a `condition: call` node. Operates on a single condition object in
|
||||
* place. When both `connected` and `muted` are present, splits into a
|
||||
* two-condition `and` (the only way to preserve both semantics now that
|
||||
* `connected` no longer lives on `microphone`).
|
||||
*
|
||||
* @returns `true` if the node was modified.
|
||||
*/
|
||||
const microphoneConnectedToCallTransform = (data: unknown): boolean => {
|
||||
if (!isRecord(data) || data['condition'] !== 'microphone') {
|
||||
return false;
|
||||
}
|
||||
const connected = data['connected'];
|
||||
if (typeof connected !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
const muted = data['muted'];
|
||||
|
||||
// Survives the rebuild below: a condition the user had switched off must not
|
||||
// come back switched on.
|
||||
const enabled = data['enabled'];
|
||||
|
||||
// A connected microphone meant a call was underway, which is either of the
|
||||
// two active phases; a disconnected one meant no call at all.
|
||||
// This needs to be revisited: https://github.com/dermotduffy/advanced-camera-card/issues/2590
|
||||
const call = connected ? ['ringing', 'answered'] : 'idle';
|
||||
|
||||
for (const key of Object.keys(data)) {
|
||||
delete data[key];
|
||||
}
|
||||
|
||||
if (typeof muted === 'boolean') {
|
||||
// `enabled` goes on the composite, disabling both halves together exactly
|
||||
// as it disabled the single condition it replaces.
|
||||
data['condition'] = 'and';
|
||||
data['conditions'] = [
|
||||
{ condition: 'call', call: call },
|
||||
{ condition: 'microphone', muted: muted },
|
||||
];
|
||||
} else {
|
||||
data['condition'] = 'call';
|
||||
data['call'] = call;
|
||||
}
|
||||
if (enabled !== undefined) {
|
||||
data['enabled'] = enabled;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Unify the legacy trio `live_substream_{on,off,select}` into the new
|
||||
// `substream_{on,off}` pair. `live_substream_select` carried the substream ID
|
||||
// in its `camera` field; that field becomes `stream` on `substream_on`.
|
||||
@@ -1748,22 +1698,6 @@ const UPGRADES = [
|
||||
upgradeWithOverrides('ptz', ptzIncorrectDataToWebRTCDataTransform),
|
||||
),
|
||||
|
||||
// microphone.connected -> call condition migration. Conditions live under
|
||||
// overrides, elements, and automations.
|
||||
upgradeArrayOfObjects(CONF_OVERRIDES, (override) =>
|
||||
upgradeObjectRecursively(microphoneConnectedToCallTransform)(override),
|
||||
),
|
||||
(data: unknown): boolean => {
|
||||
return upgradeObjectRecursively(microphoneConnectedToCallTransform)(
|
||||
typeof data === 'object' && data ? data[CONF_ELEMENTS] : {},
|
||||
);
|
||||
},
|
||||
(data: unknown): boolean => {
|
||||
return upgradeObjectRecursively(microphoneConnectedToCallTransform)(
|
||||
typeof data === 'object' && data ? data[CONF_AUTOMATIONS] : {},
|
||||
);
|
||||
},
|
||||
|
||||
// Unify `live_substream_{on,off,select}` actions. Walked over the entire
|
||||
// tree because card actions can appear anywhere (menu buttons, elements,
|
||||
// automations, view-action handlers, etc.).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const microphoneBaseSchema = z.object({
|
||||
connected: z.boolean().optional(),
|
||||
muted: z.boolean().optional(),
|
||||
});
|
||||
export type MicrophoneBase = z.infer<typeof microphoneBaseSchema>;
|
||||
|
||||
@@ -7,5 +7,4 @@ export const microphoneConditionSchema = microphoneBaseSchema
|
||||
.extend(conditionBaseSchema.shape)
|
||||
.extend({
|
||||
condition: z.literal('microphone'),
|
||||
muted: z.boolean(),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user