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
+7 -37
View File
@@ -289,24 +289,6 @@ action: custom:advanced-camera-card-action
advanced_camera_card_action: menu_toggle
```
## `microphone_connect`
Connect the microphone for [2-way audio](../../../usage/2-way-audio.md).
```yaml
action: custom:advanced-camera-card-action
advanced_camera_card_action: microphone_connect
```
## `microphone_disconnect`
Disconnect the microphone during [2-way audio](../../../usage/2-way-audio.md).
```yaml
action: custom:advanced-camera-card-action
advanced_camera_card_action: microphone_disconnect
```
## `microphone_mute`
Mute the microphone during [2-way audio](../../../usage/2-way-audio.md).
@@ -913,48 +895,36 @@ elements:
advanced_camera_card_action: menu_toggle
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-p-circle
title: Microphone connect
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: microphone_connect
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-q-circle
title: Microphone disconnect
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: microphone_disconnect
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-r-circle
title: Microphone mute
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: microphone_mute
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-s-circle
icon: mdi:alpha-q-circle
title: Microphone unmute
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: microphone_unmute
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-t-circle
icon: mdi:alpha-r-circle
title: Mute
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: mute
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-u-circle
icon: mdi:alpha-s-circle
title: Pause
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: pause
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-v-circle
icon: mdi:alpha-t-circle
title: Play
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: play
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-x-circle
icon: mdi:alpha-u-circle
title: Real PTZ Preset
tap_action:
action: custom:advanced-camera-card-action
@@ -962,14 +932,14 @@ elements:
ptz_action: preset
ptz_preset: doorway
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-y-circle
icon: mdi:alpha-v-circle
title: Show PTZ Controls
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: ptz_controls
enabled: true
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-z-circle
icon: mdi:alpha-x-circle
title: Go to precise digital location
tap_action:
action: custom:advanced-camera-card-action
+18 -18
View File
@@ -421,28 +421,28 @@ triggers:
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. |
| 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. A disconnected microphone counts as muted. See the note below. |
> [!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).
> The `connected` parameter is about **browser microphone access**. 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.
> The microphone is connected while a [two-way audio](../usage/2-way-audio.md)
> call is in progress, and closes when that call ends. It is also open for as
> long as the card runs when
> [`live.microphone.always_connected`](live.md?id=microphone) is set.
>
> To match a call in progress, use [`call`](#call) instead.
> `muted` is `true` whenever the microphone is disconnected, so outside a call
> it is always `true`. It is `false` only during a call with the microphone
> unmuted. `muted: false` is therefore the way to match "the user can be heard
> right now"; to match the absence of a call, use [`call`](#call) rather than
> `muted: true`.
## `not`
+7 -10
View File
@@ -222,13 +222,12 @@ live:
microphone:
```
| Option | Default | Description |
| ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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`. 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`. |
| Option | Default | Description |
| ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `always_connected` | `false` | Whether or not to keep the microphone connected while the card is running. By default the microphone is connected when a [two-way audio](../usage/2-way-audio.md) call needs it and disconnected when that call ends. Setting this to `true` connects it at card load and never disconnects it, which avoids the connection setup on the first call at the cost of the browser reporting the microphone as in use for as long as the card is running. |
| `auto_mute` | `[]` | 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). Use an empty list (`[]`, the default) to never automatically mute the microphone via these conditions. The microphone is always muted when a call ends. |
| `auto_unmute` | `[]` | A list of conditions in which the microphone is unmuted. `call` will automatically unmute the microphone when a [two-way audio](../usage/2-way-audio.md) call is started (or answered for inbound calls). `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. 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. Unmuting only has an effect during a call: at any other time nothing can carry the audio, so the request is ignored. |
| `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.
@@ -304,10 +303,8 @@ live:
24h: true
microphone:
always_connected: false
auto_mute:
- call
auto_mute: []
auto_unmute: []
disconnect_seconds: 90
mute_after_microphone_mute_seconds: 60
display:
mode: single
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

+3 -3
View File
@@ -62,9 +62,9 @@ note over User, Card
end note
Card --> User : Inbound audio mutes (<color:#43a047>**""live.auto_mute""**</color>)
Card -> Mic : Microphone mutes (<color:#43a047>**""live.microphone.auto_mute""**</color>)
Card -> Mic : Microphone mutes and disconnects
note over Mic
Disconnects after <color:#43a047>**""live.microphone.disconnect_seconds""**</color>
unless <color:#43a047>**""live.microphone.always_connected""**</color>
The browser stops reporting the microphone as in use, unless
<color:#43a047>**""live.microphone.always_connected""**</color> holds it open
end note
@enduml
+10 -4
View File
@@ -69,16 +69,22 @@ enabled by default and appears in the `live` view whenever the selected camera
[`always_connected`](../configuration/live.md?id=microphone) microphone option
is set to `true`. On the first call there may be a brief `webrtc` connection
reset to include 2-way audio.
- The browser asks for microphone permission when a call needs it. How often it
asks depends on the browser: Chrome remembers the choice for the site, Safari
asks once per page load, and Firefox asks for every call unless _Remember this
decision_ is ticked. Set
[`always_connected`](../configuration/live.md?id=microphone) to `true` to be
asked once at card load instead.
- While a call is in progress the card locks disruptive actions (camera and
substream changes, casting, reload, etc.) so an accidental tap, swipe, or
button press doesn't cut the call off. Set
[`live.controls.call.lock`](../configuration/live.md?id=call) to `false` to
disable this.
- End the call with the overlay's end-call button. When the call ends the
microphone and inbound audio are muted again.
- The video automatically resets to remove the microphone after the number of
seconds specified by [`disconnect_seconds`](../configuration/live.md?id=microphone)
have elapsed since the call ended.
microphone and inbound audio are muted again, and the microphone is
disconnected. The exception is
[`always_connected`](../configuration/live.md?id=microphone), which holds the
microphone connected for as long as the card is running.
Calls can also be controlled programmatically with the
[`call_start`](../configuration/actions/custom/README.md?id=call_start),
+33 -33
View File
@@ -51,39 +51,39 @@ To send an action to a specific named Advanced Camera Card:
Only a subset of all [actions](../configuration/actions/README.md) are supported in URL form.
| Action | Supported in URL | Explanation |
| ------------------------------------------------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `camera_select` | :white_check_mark: | |
| `camera_ui` | :white_check_mark: | |
| `clip` | :white_check_mark: | |
| `clips` | :white_check_mark: | |
| `default` | :white_check_mark: | |
| `diagnostics` | :white_check_mark: | |
| `download` | :heavy_multiplication_x: | Latest media information is not available on initial render. |
| `expand` | :white_check_mark: | |
| `folder` | :white_check_mark: | |
| `folders` | :white_check_mark: | |
| `fullscreen` | :heavy_multiplication_x: | Javascript does not support activating fullscreen without direct human interaction. Use `expand` as an alternative. |
| `gallery` | :white_check_mark: | |
| `image` | :white_check_mark: | |
| `live` | :white_check_mark: | |
| `media` | :white_check_mark: | |
| `media_player` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. |
| `menu_toggle` | :white_check_mark: | |
| `microphone_connect`, `microphone_disconnect`, `microphone_mute`, `microphone_unmute` | :heavy_multiplication_x: | |
| `mute`, `unmute` | :heavy_multiplication_x: | |
| `play`, `pause` | :heavy_multiplication_x: | |
| `ptz` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. |
| `recording` | :white_check_mark: | |
| `recordings` | :white_check_mark: | |
| `review` | :white_check_mark: | |
| `reviews` | :white_check_mark: | |
| `screenshot` | :heavy_multiplication_x: | Latest media information is not available on initial render. |
| `ptz_controls` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. |
| `snapshot` | :white_check_mark: | |
| `snapshots` | :white_check_mark: | |
| `substream_off` | :white_check_mark: | The `camera` field is not exposed via URL; the selected camera is used. Use the action config directly to target another camera. |
| `substream_on` | :white_check_mark: | Pass the substream camera ID as the URL value to engage it directly; omit the value to cycle. |
| Action | Supported in URL | Explanation |
| -------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `camera_select` | :white_check_mark: | |
| `camera_ui` | :white_check_mark: | |
| `clip` | :white_check_mark: | |
| `clips` | :white_check_mark: | |
| `default` | :white_check_mark: | |
| `diagnostics` | :white_check_mark: | |
| `download` | :heavy_multiplication_x: | Latest media information is not available on initial render. |
| `expand` | :white_check_mark: | |
| `folder` | :white_check_mark: | |
| `folders` | :white_check_mark: | |
| `fullscreen` | :heavy_multiplication_x: | Javascript does not support activating fullscreen without direct human interaction. Use `expand` as an alternative. |
| `gallery` | :white_check_mark: | |
| `image` | :white_check_mark: | |
| `live` | :white_check_mark: | |
| `media` | :white_check_mark: | |
| `media_player` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. |
| `menu_toggle` | :white_check_mark: | |
| `microphone_mute`, `microphone_unmute` | :heavy_multiplication_x: | |
| `mute`, `unmute` | :heavy_multiplication_x: | |
| `play`, `pause` | :heavy_multiplication_x: | |
| `ptz` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. |
| `recording` | :white_check_mark: | |
| `recordings` | :white_check_mark: | |
| `review` | :white_check_mark: | |
| `reviews` | :white_check_mark: | |
| `screenshot` | :heavy_multiplication_x: | Latest media information is not available on initial render. |
| `ptz_controls` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. |
| `snapshot` | :white_check_mark: | |
| `snapshots` | :white_check_mark: | |
| `substream_off` | :white_check_mark: | The `camera` field is not exposed via URL; the selected camera is used. Use the action config directly to target another camera. |
| `substream_on` | :white_check_mark: | Pass the substream camera ID as the URL value to engage it directly; omit the value to cycle. |
## Examples
@@ -1,11 +0,0 @@
import type { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
import type { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class MicrophoneConnectAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getMicrophoneManager().connect();
}
}
@@ -1,11 +0,0 @@
import type { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
import type { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class MicrophoneDisconnectAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getMicrophoneManager().disconnect();
}
}
-6
View File
@@ -28,8 +28,6 @@ import { InternalCallbackAction } from './actions/internal-callback';
import { LogAction } from './actions/log';
import { MediaPlayerAction } from './actions/media-player';
import { MenuToggleAction } from './actions/menu-toggle';
import { MicrophoneConnectAction } from './actions/microphone-connect';
import { MicrophoneDisconnectAction } from './actions/microphone-disconnect';
import { MicrophoneMuteAction } from './actions/microphone-mute';
import { MicrophoneUnmuteAction } from './actions/microphone-unmute';
import { MoreInfoAction } from './actions/more-info';
@@ -159,10 +157,6 @@ export class ActionFactory {
return new SubstreamOnAction(context, action, options?.config);
case 'media_player':
return new MediaPlayerAction(context, action, options?.config);
case 'microphone_connect':
return new MicrophoneConnectAction(context, action, options?.config);
case 'microphone_disconnect':
return new MicrophoneDisconnectAction(context, action, options?.config);
case 'microphone_mute':
return new MicrophoneMuteAction(context, action, options?.config);
case 'microphone_unmute':
+48 -18
View File
@@ -109,7 +109,7 @@ export class CallManager {
// granting microphone access -- so connecting here would let a refusal stop
// the call from ever ringing. The connect is deferred to `answer()`. An
// outbound call is answered by construction and needs it immediately.
if (!inbound && !(await this._connectMicrophone())) {
if (!inbound && !(await this._grantTransmissionAndConnect())) {
return false;
}
@@ -137,7 +137,8 @@ export class CallManager {
if (inbound && (existingCall.answered || !existingCall.inbound)) {
return false;
}
this._end(false);
// The replacement call inherits the microphone.
this._end(false, { retainMicrophone: true });
}
// Outbound calls are answered by construction (the user initiated them);
@@ -152,9 +153,16 @@ 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();
// `_grantTransmissionAndConnect` above reported the need for transmission
// before awaiting the microphone connect; a call ending during that await
// may have withdrawn it since, so an answered session reports it again --
// ahead of the view and condition state below, whose listeners may replace
// the session. A ringing session reports nothing: it transmits nothing,
// and reporting inactive could end a transmission another request is
// using.
if (answered) {
this._api.getMicrophoneManager().setTransmissionActive(true);
}
this._api.getViewManager().setViewByParameters({
...(needsNavigation && {
@@ -219,13 +227,14 @@ export class CallManager {
// An inbound call rings without the microphone, so this is where it is
// connected -- under the user gesture that answering provides. The call is
// left ringing on failure so it can be answered again.
if (!(await this._connectMicrophone())) {
if (!(await this._grantTransmissionAndConnect())) {
return false;
}
// The call may have ended, or been superseded by another, while the
// microphone connect was in flight -- there is then nothing left to answer.
if (this._call !== call) {
this._revokeTransmissionIfNoAnsweredCall();
return false;
}
@@ -287,9 +296,9 @@ export class CallManager {
this._initGeneration.invalidate();
this._ringtone.stop();
this._unansweredTimer.stop();
this._api.getMicrophoneManager().setTransmissionActive(false);
if (this._call) {
this._call = null;
this._api.getMicrophoneManager().stopUsing();
this._api.getConditionStateManager().setState({ call: 'idle' });
}
this._api
@@ -302,8 +311,9 @@ export class CallManager {
// is `false` for auto-ends (navigating away, camera/substream change), where
// the user has already chosen a destination and the pre-call view is
// deliberately not reinstated; only the manager's own auto-end paths pass it.
// `retainMicrophone` does not relinquish the microphone.
// Returns true iff a call was actually ended.
private _end(restoreView: boolean): boolean {
private _end(restoreView: boolean, options?: { retainMicrophone?: boolean }): boolean {
if (!this._call) {
return false;
}
@@ -319,9 +329,9 @@ 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();
if (!options?.retainMicrophone && call.answered) {
this._api.getMicrophoneManager().setTransmissionActive(false);
}
const viewManager = this._api.getViewManager();
@@ -424,9 +434,29 @@ export class CallManager {
return true;
}
private _revokeTransmissionIfNoAnsweredCall(): void {
if (!this._call?.answered) {
this._api.getMicrophoneManager().setTransmissionActive(false);
}
}
// The microphone manager releases a stream that connects while transmission
// is inactive, so transmission is activated first and revoked on failure.
// Returns true iff the microphone is connected and this request is still
// current.
private async _grantTransmissionAndConnect(): Promise<boolean> {
this._api.getMicrophoneManager().setTransmissionActive(true);
if (!(await this._connectMicrophone())) {
this._revokeTransmissionIfNoAnsweredCall();
return false;
}
return true;
}
// Connects the microphone for a call. Returns true iff it is connected and
// this request still belongs to the current init/uninit lifecycle. A connect
// failure is surfaced as a notification.
// this request still belongs to the current init/uninit lifecycle. A denied
// connect is surfaced as a notification; a connect superseded by a newer
// request fails silently (the newer request owns the outcome).
private async _connectMicrophone(): Promise<boolean> {
const microphoneManager = this._api.getMicrophoneManager();
if (microphoneManager.isConnected()) {
@@ -435,11 +465,12 @@ export class CallManager {
const initGeneration = this._initGeneration.current();
let connected = false;
let forbidden = false;
try {
await microphoneManager.connect();
connected = true;
connected = await microphoneManager.connect();
} catch {
// Reported below, once this request is known to still be the current one.
forbidden = true;
}
// If the init/uninit lifecycle advanced while the connect was in flight,
@@ -450,11 +481,10 @@ export class CallManager {
return false;
}
if (!connected) {
if (forbidden) {
this._notifyError('error.call_microphone_forbidden');
return false;
}
return true;
return connected;
}
private _hasCallCapability(cameraID: string): boolean {
+94 -59
View File
@@ -1,6 +1,6 @@
import { localize } from '../localize/localize';
import { AdvancedCameraCardError } from '../types';
import { Timer } from '../utils/timer';
import { Generation } from '../utils/concurrency/generation';
import type { CardMicrophoneAPI, MicrophoneState } from './types';
export class MicrophoneNotSupportedError extends AdvancedCameraCardError {
@@ -11,8 +11,11 @@ export class MicrophoneNotSupportedError extends AdvancedCameraCardError {
export class MicrophoneManager {
private _api: CardMicrophoneAPI;
private _stream?: MediaStream | null;
private _timer = new Timer();
private _stream: MediaStream | null = null;
// Whether the browser denied the most recent microphone request. Cleared by
// a later successful connect.
private _forbidden = false;
private _state: MicrophoneState = {
connected: false,
@@ -25,9 +28,14 @@ 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;
// Whether an outgoing audio path is active, i.e. something is consuming the
// microphone stream. While active, mute only disables the tracks so unmute is
// instant; while inactive, a muted microphone is released outright.
private _transmissionActive = false;
// Guards in-flight getUserMedia requests: a result that resolves after a
// newer connect or a release must not be installed.
private _connectGeneration = new Generation();
constructor(api: CardMicrophoneAPI) {
this._api = api;
@@ -57,72 +65,92 @@ export class MicrophoneManager {
return !!navigator.mediaDevices?.getUserMedia;
}
public async connect(): Promise<void> {
// Returns true iff the microphone is connected when this request completes:
// a request superseded by a newer connect or a release resolves false, as
// does one whose stream is immediately released for want of an active
// transmission. A denied request throws.
public async connect(): Promise<boolean> {
if (!this.isSupported()) {
throw new MicrophoneNotSupportedError();
}
const generation = this._connectGeneration.next();
let stream: MediaStream;
try {
this._stream = await navigator.mediaDevices.getUserMedia({
stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false,
});
} catch (e: unknown) {
this._stream = null;
this._setState();
// A stale rejection must not mark the microphone forbidden.
if (this._connectGeneration.isCurrent(generation)) {
this._releaseStream();
this._forbidden = true;
this._setState();
}
throw e;
}
this._setDesiredMuteOnStream();
if (!this._connectGeneration.isCurrent(generation)) {
// Superseded while the permission prompt was up: this stream must not
// survive as an open capture nothing is tracking.
this._stopTracks(stream);
return false;
}
// A connect over an existing stream must not leak the tracks of the
// stream it replaces.
this._stopTracks(this._stream);
this._stream = stream;
this._forbidden = false;
this._reconcile();
this._setState();
return this.isConnected();
}
// Reports whether an outgoing audio path is active. When transmission ends,
// the microphone returns to muted and -- unless `always_connected` -- the
// device is released.
public setTransmissionActive(transmissionActive: boolean): void {
if (this._transmissionActive === transmissionActive) {
return;
}
this._transmissionActive = transmissionActive;
if (!transmissionActive) {
this._desireMute = true;
}
this._reconcile();
this._setState();
}
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;
public getStream(): MediaStream | null {
return this._stream;
}
public mute(): void {
this._desireMute = true;
this._setDesiredMuteOnStream();
this._reconcile();
this._setState();
}
public async unmute(): Promise<void> {
if (!this.isSupported()) {
// An unmute without an active outgoing audio path is meaningless: nothing
// consumes the stream, so enabling (or creating) a capture would only light
// the browser recording indicator.
if (!this.isSupported() || !this._transmissionActive) {
return;
}
this._desireMute = false;
if (!this.isConnected() && !this.isForbidden()) {
// Connecting will automatically set the desired mute.
// Connecting applies the desired mute to the new stream.
await this.connect();
} else if (this.isConnected()) {
this._setDesiredMuteOnStream();
this._setState();
return;
}
this._reconcile();
this._setState();
}
public isConnected(): boolean {
@@ -130,7 +158,7 @@ export class MicrophoneManager {
}
public isForbidden(): boolean {
return this._stream === null;
return this._forbidden;
}
public isMuted(): boolean {
@@ -139,28 +167,35 @@ export class MicrophoneManager {
return !this._stream || this._stream.getTracks().every((track) => !track.enabled);
}
private _setDesiredMuteOnStream(): void {
this._stream?.getTracks().forEach((track) => {
track.enabled = !this._desireMute;
});
this._startDisconnectTimer();
private _stopTracks(stream: MediaStream | null): void {
stream?.getTracks().forEach((track) => track.stop());
}
private _startDisconnectTimer(): void {
const microphoneConfig = this._api.getConfigManager().getConfig()?.live.microphone;
private _releaseStream(): void {
this._connectGeneration.invalidate();
this._stopTracks(this._stream);
this._stream = null;
}
if (microphoneConfig?.always_connected || this._inUse) {
// The single place that applies microphone policy to the device: whether
// the device is held or released, and whether its tracks are live. A muted
// microphone with no active transmission is released entirely (turning the
// browser recording indicator off) unless `always_connected`; while
// transmission is active, mute only disables the tracks so unmute needs no
// new permission request or renegotiation.
private _reconcile(): void {
if (!this._stream) {
return;
}
const disconnectSeconds = microphoneConfig?.disconnect_seconds ?? 0;
if (disconnectSeconds) {
this._timer.start(disconnectSeconds, () => {
this.disconnect();
});
const alwaysConnected = !!this._api.getConfigManager().getConfig()?.live.microphone
?.always_connected;
if (this._desireMute && !this._transmissionActive && !alwaysConnected) {
this._releaseStream();
return;
}
this._stream.getTracks().forEach((track) => {
track.enabled = !this._desireMute;
});
}
private _setState(): void {
@@ -31,10 +31,8 @@ export const getLiveAutoUnmuteOptions = (): HASelectSelectorOption[] => [
condition('call', 'call_unmute'),
];
export const getMicrophoneMuteOptions = (): HASelectSelectorOption[] => [
...getMediaActionNegativeOptions(),
condition('call', 'call_mute'),
];
export const getMicrophoneMuteOptions = (): HASelectSelectorOption[] =>
getMediaActionNegativeOptions();
export const getMicrophoneUnmuteOptions = (): HASelectSelectorOption[] => [
...getMediaActionPositiveOptions(),
-1
View File
@@ -103,7 +103,6 @@ const getMicrophoneSchema = (): HAFormExpandableSchema => ({
title: localize('config.live.microphone.editor_label'),
icon: 'mdi:microphone',
schema: [
{ name: 'disconnect_seconds', selector: createNumberSelector({ min: 0 }) },
{ name: 'always_connected', selector: { boolean: {} } },
{
name: 'auto_mute',
@@ -47,18 +47,17 @@ export class MicrophoneActionsController {
* when the live view first appears during an answered call.
*
* Call answer unmutes the microphone only if the user opted into
* `microphone.auto_unmute: ['call']`; the symmetric mute fires on the
* answered-to-unanswered transition (call end after answer).
* `microphone.auto_unmute: ['call']`. There is no symmetric mute: the
* microphone manager mutes and releases the microphone itself when the call
* ends.
*/
public setCallAnswered(answered: boolean): void {
public async setCallAnswered(answered: boolean): Promise<void> {
if (answered === this._callAnswered) {
return;
}
this._callAnswered = answered;
if (answered) {
void this._unmuteIfConfigured('call');
} else {
this._muteIfConfigured('call');
await this._unmuteIfConfigured('call');
}
}
+3 -1
View File
@@ -98,7 +98,9 @@ export class AdvancedCameraCardLive extends LitElement {
// ['call']` doesn't transmit audio during the pre-answer ringing state.
// Outbound calls are answered at construction; inbound calls only after
// the user accepts.
this._microphoneActionsController.setCallAnswered(!!this.call?.answered);
this._microphoneActionsController
.setCallAnswered(!!this.call?.answered)
.catch(() => {});
}
}
+87
View File
@@ -1494,6 +1494,87 @@ const triggersEventsToMediaEventsTransform = (triggers: unknown): unknown => {
return result;
};
const REMOVED_MICROPHONE_ACTIONS = ['microphone_connect', 'microphone_disconnect'];
// The properties that hold actions: the tap handlers of elements, menu buttons,
// notification controls and views (`actionsBaseSchema`), the actions of an
// automation, and the branches of an `if` action. Each holds either a single
// action or a list of them.
const ACTION_PROPERTIES = [
'actions',
'double_tap_action',
'else',
'end_tap_action',
'hold_action',
'start_tap_action',
'tap_action',
'then',
];
const isRemovedMicrophoneAction = (data: unknown): boolean =>
isRecord(data) &&
(data['action'] === 'fire-dom-event' ||
data['action'] === 'custom:advanced-camera-card-action') &&
typeof data['advanced_camera_card_action'] === 'string' &&
REMOVED_MICROPHONE_ACTIONS.includes(data['advanced_camera_card_action']);
/**
* Remove the `microphone_connect` / `microphone_disconnect` actions wherever
* they appear. The whole tree is walked because card actions can appear
* anywhere (menu buttons, elements, automations, view-action handlers, etc.),
* but only the properties that hold actions are touched, so an object that
* merely resembles an action -- the `data` of a `perform-action`, for
* instance -- is left as the user wrote it.
*
* A property holding a single such action is deleted, since every one of those
* is optional. A list keeps its property even when it empties: some are
* required (`automations[].actions`, an `if` action's `then`) and an empty one
* is valid everywhere.
*/
const removeMicrophoneActionsTransform = (data: unknown): boolean => {
// Arrays are records too, so their entries are walked by the loop below.
if (!isRecord(data)) {
return false;
}
let modified = false;
for (const key of Object.keys(data)) {
if (ACTION_PROPERTIES.includes(key)) {
const value = data[key];
if (isRemovedMicrophoneAction(value)) {
delete data[key];
modified = true;
continue;
}
if (Array.isArray(value)) {
const kept = value.filter((item) => !isRemovedMicrophoneAction(item));
if (kept.length !== value.length) {
data[key] = kept;
modified = true;
}
}
}
modified = removeMicrophoneActionsTransform(data[key]) || modified;
}
return modified;
};
/**
* Remove a value from an array. The array is kept even when it empties: inside
* an override, deleting it would restore whatever the base configuration says
* rather than leaving nothing. An array that does not hold the value is left
* alone.
*/
const removeFromArrayTransform = (removed: unknown): ((value: unknown) => unknown) => {
return (value: unknown): unknown => {
if (!Array.isArray(value) || !value.includes(removed)) {
return undefined;
}
return value.filter((item) => item !== removed);
};
};
const UPGRADES = [
// v5.2.0 -> v6.0.0
(data: unknown): boolean => {
@@ -1729,4 +1810,10 @@ const UPGRADES = [
isRecord(data) ? data : {},
);
},
// Retire microphone parameters/actions not needed with 'call'.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2681
deleteWithOverrides('live.microphone.disconnect_seconds'),
upgradeWithOverrides('live.microphone.auto_mute', removeFromArrayTransform('call')),
removeMicrophoneActionsTransform,
];
@@ -10,8 +10,6 @@ const GENERAL_ACTIONS = [
'fullscreen',
'info',
'menu_toggle',
'microphone_connect',
'microphone_disconnect',
'microphone_mute',
'microphone_unmute',
'mute',
+1 -4
View File
@@ -13,10 +13,7 @@ export const MEDIA_UNMUTE_CONDITIONS = [
'call',
] as const;
export const MICROPHONE_MUTE_CONDITIONS = [
...MEDIA_ACTION_NEGATIVE_CONDITIONS,
'call',
] as const;
export const MICROPHONE_MUTE_CONDITIONS = [...MEDIA_ACTION_NEGATIVE_CONDITIONS] as const;
export const MICROPHONE_UNMUTE_CONDITIONS = [
...MEDIA_ACTION_POSITIVE_CONDITIONS,
+1 -6
View File
@@ -25,9 +25,8 @@ import { transitionEffectConfigSchema } from './common/transition-effect';
const microphoneConfigDefault = {
always_connected: false,
auto_mute: ['call' as const],
auto_mute: [],
auto_unmute: [],
disconnect_seconds: 90,
mute_after_microphone_mute_seconds: 60,
};
@@ -80,10 +79,6 @@ const microphoneConfigSchema = z
.enum(MICROPHONE_UNMUTE_CONDITIONS)
.array()
.default(microphoneConfigDefault.auto_unmute),
disconnect_seconds: z
.number()
.min(0)
.default(microphoneConfigDefault.disconnect_seconds),
mute_after_microphone_mute_seconds: z
.number()
.min(0)
-1
View File
@@ -255,7 +255,6 @@
"lazy_unload": "Les càmeres en directe es descarreguen amb mandra",
"microphone": {
"always_connected": "Mantingueu sempre el micròfon connectat",
"disconnect_seconds": "Segons després dels quals desconnectar el micròfon (0=mai)",
"editor_label": "Micròfon",
"mute_after_microphone_mute_seconds": "Segons després de silenciar el micròfon per silenciar l'àudio entrant"
},
-1
View File
@@ -215,7 +215,6 @@
"live": {
"microphone": {
"always_connected": "Mikrofon immer verbunden lassen",
"disconnect_seconds": "Sekunden nach denen das Mikrofon getrennt wird (0=niemals)",
"editor_label": "Mikrofon",
"mute_after_microphone_mute_seconds": "Sekunden nach denen das Mikrofon stumm geschalten wird, um eingehendes Audio stumm zu schalten"
},
-1
View File
@@ -493,7 +493,6 @@
"always_connected": "Always keep the microphone connected",
"auto_mute": "Automatically mute the microphone",
"auto_unmute": "Automatically unmute the microphone",
"disconnect_seconds": "Seconds before disconnecting microphone (0=never)",
"editor_label": "Microphone",
"mute_after_microphone_mute_seconds": "Seconds after microphone mute before muting inbound audio"
},
-1
View File
@@ -336,7 +336,6 @@
"lazy_unload": "Les caméras en direct sont déchargées en différé",
"microphone": {
"always_connected": "Toujours garder le microphone connecté",
"disconnect_seconds": "Secondes après quoi déconnecter le microphone (0=jamais)",
"editor_label": "Microphone",
"mute_after_microphone_mute_seconds": "Secondes après la mise en sourdine du microphone pour couper l'audio entrant"
},
-1
View File
@@ -378,7 +378,6 @@
"lazy_unload": "Kamery wyładowywane leniwie",
"microphone": {
"always_connected": "Zawsze utrzymuj połączenie mikrofonu",
"disconnect_seconds": "Sekundy do rozłączenia mikrofonu (0=nigdy)",
"editor_label": "Mikrofon",
"mute_after_microphone_mute_seconds": "Sekundy po wyciszeniu mikrofonu do wyciszenia dźwięku przychodzącego"
},
@@ -1,19 +0,0 @@
import { expect, it } from 'vitest';
import { MicrophoneConnectAction } from '../../../../src/card-controller/actions/actions/microphone-connect';
import { createCardAPI } from '../../../test-utils';
it('should handle microphone_connect action', async () => {
const api = createCardAPI();
const action = new MicrophoneConnectAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'microphone_connect',
},
);
await action.execute(api);
expect(api.getMicrophoneManager().connect).toHaveBeenCalled();
});
@@ -1,19 +0,0 @@
import { expect, it } from 'vitest';
import { MicrophoneDisconnectAction } from '../../../../src/card-controller/actions/actions/microphone-disconnect';
import { createCardAPI } from '../../../test-utils';
it('should handle microphone_disconnect action', async () => {
const api = createCardAPI();
const action = new MicrophoneDisconnectAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'microphone_disconnect',
},
);
await action.execute(api);
expect(api.getMicrophoneManager().disconnect).toHaveBeenCalled();
});
@@ -20,8 +20,6 @@ import { InternalCallbackAction } from '../../../src/card-controller/actions/act
import { LogAction } from '../../../src/card-controller/actions/actions/log';
import { MediaPlayerAction } from '../../../src/card-controller/actions/actions/media-player';
import { MenuToggleAction } from '../../../src/card-controller/actions/actions/menu-toggle';
import { MicrophoneConnectAction } from '../../../src/card-controller/actions/actions/microphone-connect';
import { MicrophoneDisconnectAction } from '../../../src/card-controller/actions/actions/microphone-disconnect';
import { MicrophoneMuteAction } from '../../../src/card-controller/actions/actions/microphone-mute';
import { MicrophoneUnmuteAction } from '../../../src/card-controller/actions/actions/microphone-unmute';
import { MoreInfoAction } from '../../../src/card-controller/actions/actions/more-info';
@@ -146,14 +144,6 @@ describe('ActionFactory', () => {
MediaPlayerAction,
],
[{ advanced_camera_card_action: 'menu_toggle' as const }, MenuToggleAction],
[
{ advanced_camera_card_action: 'microphone_connect' as const },
MicrophoneConnectAction,
],
[
{ advanced_camera_card_action: 'microphone_disconnect' as const },
MicrophoneDisconnectAction,
],
[
{ advanced_camera_card_action: 'microphone_mute' as const },
MicrophoneMuteAction,
+356 -38
View File
@@ -527,7 +527,7 @@ describe('start', () => {
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue();
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(true);
expect(await new CallManager(api).start()).toBe(true);
@@ -1426,7 +1426,7 @@ describe('answer', () => {
microphoneConnected: false,
config: inboundConfig,
});
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue();
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(true);
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
@@ -1442,9 +1442,9 @@ describe('answer', () => {
microphoneConnected: false,
config: inboundConfig,
});
let resolveConnect: () => void = () => {};
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve) => {
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
@@ -1459,7 +1459,7 @@ describe('answer', () => {
// microphone permission prompt to be dealt with.
expect(getRingtone().stop).toHaveBeenCalled();
resolveConnect();
resolveConnect(true);
expect(await answerPromise).toBe(true);
});
@@ -1540,22 +1540,26 @@ describe('answer', () => {
});
});
describe('microphone usage', () => {
it('should mark the microphone in use for the duration of the call', async () => {
describe('microphone transmission', () => {
it('should transmit 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).toHaveBeenCalledTimes(1);
expect(api.getMicrophoneManager().stopUsing).not.toHaveBeenCalled();
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenCalledWith(true);
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
false,
);
manager.end();
expect(api.getMicrophoneManager().stopUsing).toHaveBeenCalledTimes(1);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
it('should not mark the microphone in use when the start is aborted', async () => {
it('should stop transmitting when the call fails to start', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
@@ -1564,10 +1568,18 @@ describe('microphone usage', () => {
expect(await new CallManager(api).start()).toBe(false);
expect(api.getMicrophoneManager().startUsing).not.toHaveBeenCalled();
// Transmission is granted ahead of the microphone connect, so the failed
// start must withdraw it again.
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenNthCalledWith(
1,
true,
);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
it('should keep the microphone in use when a call supersedes another', async () => {
it('should keep transmitting when one call replaces another', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
@@ -1586,26 +1598,331 @@ describe('microphone usage', () => {
expect(await manager.start()).toBe(true);
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true);
expect(api.getMicrophoneManager().startUsing).toHaveBeenCalledTimes(2);
expect(api.getMicrophoneManager().stopUsing).toHaveBeenCalledTimes(1);
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
false,
);
});
it('should mark the microphone unused on uninitialization', async () => {
it('should stop transmitting when uninitialized during a call', 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).toHaveBeenCalledTimes(1);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
it('should not mark the microphone unused when uninitializing without a call', () => {
it('should stop transmitting when uninitialized without a call', () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
new CallManager(api).uninitialize();
expect(api.getMicrophoneManager().stopUsing).not.toHaveBeenCalled();
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenCalledWith(false);
});
it('should leave transmission alone when an unanswered inbound call ends', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.end()).toBe(true);
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalled();
});
it('should stop transmitting when uninitialized while a call connects the microphone', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
const manager = new CallManager(api);
manager.initialize();
const startPromise = manager.start();
manager.uninitialize();
// The grant must not outlive the manager while the connect is still
// pending.
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
resolveConnect(true);
expect(await startPromise).toBe(false);
});
it('should let a later call stop transmitting after an abandoned call is uninitialized', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
const manager = new CallManager(api);
manager.initialize();
// A start from the first lifecycle never gets its microphone.
const stalledStart = manager.start();
manager.uninitialize();
manager.initialize();
// A call in the new lifecycle must end normally: the stalled request from
// the old lifecycle no longer holds transmission.
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(true);
expect(await manager.start()).toBe(true);
expect(manager.end()).toBe(true);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
resolveConnect(true);
expect(await stalledStart).toBe(false);
});
it('should transmit for a call that starts while another call is ending', 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);
manager.initialize();
expect(await manager.start()).toBe(true);
// The superseding start is suspended on the microphone while the answered
// call ends and reports transmission inactive.
const second = manager.start({ cameraID: 'camera.garage' });
expect(manager.end()).toBe(true);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
expect(await second).toBe(true);
// The installed session restated its need so the user can be heard on unmute.
expect(manager.getCall()?.cameraID).toBe('camera.garage');
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
true,
);
});
it('should not transmit for an inbound call that rings after another call ends', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
expect(manager.end()).toBe(true);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
expect(await manager.start({ inbound: true })).toBe(true);
// A ring transmits nothing, so it must not restate a need for
// transmission.
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
it('should stop transmitting when a call ends and the next cannot get the microphone', 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);
manager.initialize();
expect(await manager.start()).toBe(true);
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(false);
let rejectConnect: (reason: unknown) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<boolean>((_, reject) => {
rejectConnect = reject;
}),
);
const second = manager.start({ cameraID: 'camera.garage' });
expect(manager.end()).toBe(true);
rejectConnect(new Error());
expect(await second).toBe(false);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
it('should abort a call when the microphone does not stay connected', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
const manager = new CallManager(api);
manager.initialize();
const start = manager.start();
// The microphone manager releases a stream it connected while nothing was
// transmitting, and reports that the microphone is not connected.
resolveConnect(false);
expect(await start).toBe(false);
expect(manager.isActive()).toBe(false);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
it('should not transmit while an inbound call rings', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalled();
});
it('should allow transmission before connecting the microphone on answer', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(true);
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(await manager.answer()).toBe(true);
// If transmission is not yet active when a connect completes, the
// microphone manager immediately releases the new stream -- so the answer
// must activate transmission before it connects. The ringing start touches
// neither method, so index 0 is each method's first and only invocation,
// made by the answer.
const transmitOrder = vi.mocked(api.getMicrophoneManager().setTransmissionActive)
.mock.invocationCallOrder[0];
const connectOrder = vi.mocked(api.getMicrophoneManager().connect).mock
.invocationCallOrder[0];
expect(transmitOrder).toBeLessThan(connectOrder);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
true,
);
});
it('should stop transmitting when the microphone connect fails on answer', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error());
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(await manager.answer()).toBe(false);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
expect(manager.getCall()?.answered).toBe(false);
});
it('should keep transmitting when a failed start leaves an earlier call in progress', 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);
// The second start fails its microphone connect, but the first call is
// still answered and holds the transmission grant.
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(false);
vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error());
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(false);
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
false,
);
expect(manager.getCall()?.cameraID).toBe('camera.office');
});
it('should stop transmitting when the call ends while answering connects the microphone', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
const answerPromise = manager.answer();
manager.end();
resolveConnect(true);
expect(await answerPromise).toBe(false);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
});
@@ -1839,9 +2156,9 @@ describe('state changes during in-flight start', () => {
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: () => void = () => {};
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve) => {
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
@@ -1850,7 +2167,7 @@ describe('state changes during in-flight start', () => {
const startPromise = manager.start();
manager.uninitialize();
resolveConnect();
resolveConnect(true);
expect(await startPromise).toBe(false);
expect(manager.isActive()).toBe(false);
@@ -1862,9 +2179,9 @@ describe('state changes during in-flight start', () => {
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: () => void = () => {};
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve) => {
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
@@ -1874,7 +2191,7 @@ describe('state changes during in-flight start', () => {
const startPromise = manager.start();
manager.uninitialize();
manager.initialize();
resolveConnect();
resolveConnect(true);
expect(await startPromise).toBe(false);
expect(manager.isActive()).toBe(false);
@@ -1896,9 +2213,9 @@ describe('state changes during in-flight start', () => {
},
]),
});
let resolveConnect: () => void = () => {};
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve) => {
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
@@ -1908,17 +2225,18 @@ describe('state changes during in-flight start', () => {
// Both requests read the (absent) session before either can install one.
const first = manager.start();
const second = manager.start({ cameraID: 'camera.garage' });
resolveConnect();
resolveConnect(true);
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.
// overwrite it, leaving exactly one live session with transmission still
// granted.
expect(manager.getCall()?.cameraID).toBe('camera.garage');
expect(api.getMicrophoneManager().startUsing).toHaveBeenCalledTimes(2);
expect(api.getMicrophoneManager().stopUsing).toHaveBeenCalledTimes(1);
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
false,
);
});
it('should suppress the microphone-failure notification when uninitialized mid-await', async () => {
@@ -1928,7 +2246,7 @@ describe('state changes during in-flight start', () => {
});
let rejectConnect: (reason: unknown) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((_, reject) => {
new Promise<boolean>((_, reject) => {
rejectConnect = reject;
}),
);
@@ -1957,7 +2275,7 @@ describe('state changes during in-flight answer', () => {
api: CardController,
): Promise<{
manager: CallManager;
resolveConnect: () => void;
resolveConnect: (connected: boolean) => void;
rejectConnect: (reason: unknown) => void;
}> => {
const manager = new CallManager(api);
@@ -1965,10 +2283,10 @@ describe('state changes during in-flight answer', () => {
expect(await manager.start({ inbound: true })).toBe(true);
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(false);
let resolveConnect: () => void = () => {};
let resolveConnect: (connected: boolean) => void = () => {};
let rejectConnect: (reason: unknown) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve, reject) => {
new Promise<boolean>((resolve, reject) => {
resolveConnect = resolve;
rejectConnect = reject;
}),
@@ -1985,7 +2303,7 @@ describe('state changes during in-flight answer', () => {
const answerPromise = manager.answer();
manager.uninitialize();
resolveConnect();
resolveConnect(true);
expect(await answerPromise).toBe(false);
expect(manager.isActive()).toBe(false);
@@ -2015,7 +2333,7 @@ describe('state changes during in-flight answer', () => {
const answerPromise = manager.answer();
expect(manager.end()).toBe(true);
resolveConnect();
resolveConnect(true);
expect(await answerPromise).toBe(false);
expect(manager.isActive()).toBe(false);
+331 -211
View File
@@ -28,7 +28,6 @@ const medialessNavigatorMock: Navigator = {
describe('MicrophoneManager', () => {
beforeEach(() => {
vi.stubGlobal('navigator', navigatorMock);
vi.useRealTimers();
});
afterEach(() => {
@@ -44,30 +43,100 @@ describe('MicrophoneManager', () => {
return stream;
};
// Streams from `createMockStream` carry exactly one track.
const getTrack = (stream: MediaStream): MediaStreamTrack => stream.getTracks()[0];
const createDeferredStream = (): {
promise: Promise<MediaStream>;
resolve: (stream: MediaStream) => void;
reject: (error: Error) => void;
} => {
let resolveStream: ((stream: MediaStream) => void) | null = null;
let rejectStream: ((error: Error) => void) | null = null;
const promise = new Promise<MediaStream>((resolve, reject) => {
resolveStream = resolve;
rejectStream = reject;
});
return {
promise,
resolve: (stream: MediaStream) => resolveStream?.(stream),
reject: (error: Error) => rejectStream?.(error),
};
};
it('should be muted on creation', () => {
const manager = new MicrophoneManager(createCardAPI());
expect(manager).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
});
it('should be undefined without creation', () => {
it('should have no stream before connecting', () => {
const manager = new MicrophoneManager(createCardAPI());
expect(manager.getStream()).toBeUndefined();
expect(manager.getStream()).toBeNull();
});
it('should connect', async () => {
it('should connect while transmission is active', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
manager.setTransmissionActive(true);
expect(await manager.connect()).toBe(true);
expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(stream);
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalled();
});
it('should release a stream that connects without active transmission', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
expect(await manager.connect()).toBe(false);
expect(manager.isConnected()).toBeFalsy();
expect(manager.isForbidden()).toBeFalsy();
expect(getTrack(stream).stop).toHaveBeenCalled();
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: expect.objectContaining({ connected: false, muted: true }),
}),
);
});
it('should hold a muted stream without transmission when always connected', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
},
},
}),
);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(stream);
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalled();
expect(getTrack(stream).stop).not.toHaveBeenCalled();
manager.mute();
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
expect(getTrack(stream).stop).not.toHaveBeenCalled();
});
it('should be unsupported without browser support', () => {
@@ -104,24 +173,74 @@ describe('MicrophoneManager', () => {
expect(api.getCardElementManager().update).toHaveBeenCalled();
});
it('should mute and unmute', async () => {
it('should mute and unmute while transmission is active', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.connect();
expect(manager.isMuted()).toBeTruthy();
manager.setTransmissionActive(true);
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
manager.mute();
await manager.connect();
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2);
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(3);
manager.mute();
expect(manager.isMuted()).toBeTruthy();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(4);
// Unmuting again reuses the held stream rather than re-requesting the
// device.
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
expect(navigatorMock.mediaDevices.getUserMedia).toHaveBeenCalledTimes(1);
});
it('should not unmute without active transmission', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.unmute();
expect(navigatorMock.mediaDevices.getUserMedia).not.toHaveBeenCalled();
expect(manager.isConnected()).toBeFalsy();
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).not.toHaveBeenCalled();
});
it('should not unmute without active transmission when always connected', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
},
},
}),
);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
await manager.connect();
expect(manager.isMuted()).toBeTruthy();
await manager.unmute();
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
});
it('should not unmute when microphone forbidden', async () => {
@@ -129,14 +248,15 @@ describe('MicrophoneManager', () => {
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockRejectedValue(new Error());
manager.setTransmissionActive(true);
await expect(manager.connect()).rejects.toThrow(Error);
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
await manager.unmute();
expect(manager.isForbidden()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
});
it('should not unmute when not supported', async () => {
@@ -150,13 +270,14 @@ describe('MicrophoneManager', () => {
expect(manager.isMuted()).toBeTruthy();
});
it('should connect on unmute', async () => {
it('should connect on unmute while transmission is active', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
manager.setTransmissionActive(true);
expect(manager.isConnected()).toBeFalsy();
await manager.unmute();
@@ -167,236 +288,234 @@ describe('MicrophoneManager', () => {
expect(api.getCardElementManager().update).toHaveBeenCalled();
});
it('should disconnect', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
manager.disconnect();
expect(manager.isConnected()).toBeFalsy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2);
});
it('should automatically disconnect', 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();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeFalsy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2);
});
it('should not automatically disconnect when always connected', 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: true,
disconnect_seconds: disconnectSeconds,
},
},
}),
);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
});
describe('should stay connected while in use', () => {
const disconnectSeconds = 10;
const createManagerWithDisconnectSeconds = (): MicrophoneManager => {
describe('should follow transmission state', () => {
it('should release the stream and reset mute when transmission ends', async () => {
const api = createCardAPI();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
const manager = new MicrophoneManager(api);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
manager.setTransmissionActive(true);
await manager.connect();
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
manager.setTransmissionActive(false);
expect(manager.isConnected()).toBeFalsy();
expect(getTrack(stream).stop).toHaveBeenCalled();
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: expect.objectContaining({ connected: false, muted: true }),
}),
);
// The next transmission must not start with a hot microphone: the desired mute
// was reset when the previous transmission ended.
const newStream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(newStream);
manager.setTransmissionActive(true);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
});
it('should keep the stream muted when transmission ends and always connected', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: false,
disconnect_seconds: disconnectSeconds,
always_connected: true,
},
},
}),
);
return new MicrophoneManager(api);
};
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
it('should not automatically disconnect while a user is registered', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
manager.setTransmissionActive(true);
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.isMuted()).toBeFalsy();
manager.setTransmissionActive(false);
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
expect(getTrack(stream).stop).not.toHaveBeenCalled();
});
it('should restart the countdown in full when use stops', async () => {
vi.useFakeTimers();
it('should ignore the end of a transmission that never started', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
},
},
}),
);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
manager.startUsing();
vi.advanceTimersByTime(disconnectSeconds * 1000);
manager.stopUsing();
vi.advanceTimersByTime(disconnectSeconds * 1000 - 1);
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(manager.isConnected()).toBeFalsy();
});
manager.setTransmissionActive(false);
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();
expect(getTrack(stream).stop).not.toHaveBeenCalled();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
});
it('should not reconnect after an explicit disconnect while in use', async () => {
vi.useFakeTimers();
it('should ignore a transmission report that changes nothing', () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
manager.startUsing();
manager.disconnect();
manager.setTransmissionActive(true);
manager.setTransmissionActive(true);
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeFalsy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
});
});
it('should not disconnect after being explicitly disconnected and reconnected', async () => {
vi.useFakeTimers();
describe('should guard in-flight connections', () => {
it('should release a stream whose connect completes after a mute', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
const deferred = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockReturnValue(
deferred.promise,
);
const disconnectSeconds = 10;
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
const connectPromise = manager.connect();
manager.mute();
deferred.resolve(stream);
await connectPromise;
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: false,
disconnect_seconds: disconnectSeconds,
},
},
}),
);
expect(manager.isConnected()).toBeFalsy();
expect(getTrack(stream).stop).toHaveBeenCalled();
});
await manager.connect();
it('should release a stream whose connect completes after transmission ends', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
const deferred = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockReturnValue(
deferred.promise,
);
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
manager.disconnect();
await manager.connect();
manager.setTransmissionActive(true);
const connectPromise = manager.connect();
manager.setTransmissionActive(false);
deferred.resolve(stream);
await connectPromise;
// 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();
expect(manager.isConnected()).toBeFalsy();
expect(getTrack(stream).stop).toHaveBeenCalled();
});
it('should not install a stream whose connect completes after the held stream is released', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const held = createMockStream();
const late = createMockStream();
const deferred = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockResolvedValueOnce(held)
.mockReturnValueOnce(deferred.promise);
manager.setTransmissionActive(true);
await manager.connect();
const connectPromise = manager.connect();
// Releasing the held stream also invalidates the connect still in
// flight.
manager.setTransmissionActive(false);
deferred.resolve(late);
await connectPromise;
expect(manager.isConnected()).toBeFalsy();
expect(getTrack(held).stop).toHaveBeenCalled();
expect(getTrack(late).stop).toHaveBeenCalled();
});
it('should keep only the newest of overlapping connects', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const streamA = createMockStream();
const streamB = createMockStream();
const deferredA = createDeferredStream();
const deferredB = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockReturnValueOnce(deferredA.promise)
.mockReturnValueOnce(deferredB.promise);
manager.setTransmissionActive(true);
const connectA = manager.connect();
const connectB = manager.connect();
deferredA.resolve(streamA);
deferredB.resolve(streamB);
expect(await connectA).toBe(false);
expect(await connectB).toBe(true);
expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(streamB);
expect(getTrack(streamA).stop).toHaveBeenCalled();
expect(getTrack(streamB).stop).not.toHaveBeenCalled();
});
it('should not mark forbidden on a stale connect rejection', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const streamB = createMockStream();
const deferredA = createDeferredStream();
const deferredB = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockReturnValueOnce(deferredA.promise)
.mockReturnValueOnce(deferredB.promise);
manager.setTransmissionActive(true);
const connectA = manager.connect();
const connectB = manager.connect();
deferredA.reject(new Error());
deferredB.resolve(streamB);
await expect(connectA).rejects.toThrow(Error);
await connectB;
expect(manager.isForbidden()).toBeFalsy();
expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(streamB);
});
it('should stop the replaced stream when connecting over an existing stream', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const streamA = createMockStream();
const streamB = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockResolvedValueOnce(streamA)
.mockResolvedValueOnce(streamB);
manager.setTransmissionActive(true);
await manager.connect();
expect(manager.getStream()).toBe(streamA);
await manager.connect();
expect(manager.getStream()).toBe(streamB);
expect(getTrack(streamA).stop).toHaveBeenCalled();
expect(getTrack(streamB).stop).not.toHaveBeenCalled();
});
});
describe('should require initialization', async () => {
@@ -457,7 +576,7 @@ describe('MicrophoneManager', () => {
manager.initialize();
expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({
microphone: { connected: false, muted: true, forbidden: false, stream: undefined },
microphone: { connected: false, muted: true, forbidden: false, stream: null },
});
});
@@ -469,6 +588,7 @@ describe('MicrophoneManager', () => {
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
manager.setTransmissionActive(true);
await manager.connect();
let expectedState: MicrophoneState = {
@@ -515,11 +635,11 @@ describe('MicrophoneManager', () => {
}),
);
manager.disconnect();
manager.setTransmissionActive(false);
expectedState = {
forbidden: false,
stream: undefined,
stream: null,
connected: false,
muted: true,
};
@@ -309,7 +309,7 @@ describe('MicrophoneActionsController', () => {
});
describe('on call answered state change', () => {
it('should unmute on call answer when call is a configured unmute condition', () => {
it('should unmute on call answer when call is a configured unmute condition', async () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({
@@ -317,13 +317,13 @@ describe('MicrophoneActionsController', () => {
autoUnmuteConditions: ['call' as const],
});
controller.setCallAnswered(false);
controller.setCallAnswered(true);
await controller.setCallAnswered(false);
await controller.setCallAnswered(true);
expect(microphoneManager.unmute).toHaveBeenCalledTimes(1);
});
it('should unmute when the call is already answered on first notification', () => {
it('should unmute when the call is already answered on first notification', async () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({
@@ -334,41 +334,42 @@ describe('MicrophoneActionsController', () => {
// `setCallAnswered(true)` is the first call-state signal, with no
// preceding `false` -- as for a live view that mounts while a call is
// already answered. The initial state must not be swallowed as a baseline.
controller.setCallAnswered(true);
await controller.setCallAnswered(true);
expect(microphoneManager.unmute).toHaveBeenCalledTimes(1);
});
it('should mute on call end when call is a configured mute condition', () => {
it('should not mute on call end', async () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({
microphoneManager,
autoMuteConditions: ['call' as const],
});
controller.setCallAnswered(true);
controller.setCallAnswered(false);
expect(microphoneManager.mute).toHaveBeenCalledTimes(1);
});
it('should not act on the initial call state', () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({
microphoneManager,
autoMuteConditions: ['call' as const],
autoUnmuteConditions: ['call' as const],
});
controller.setCallAnswered(false);
await controller.setCallAnswered(true);
await controller.setCallAnswered(false);
// The microphone manager mutes and releases the microphone itself when
// the call ends.
expect(microphoneManager.mute).not.toHaveBeenCalled();
});
it('should not act on the initial call state', async () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({
microphoneManager,
autoUnmuteConditions: ['call' as const],
});
await controller.setCallAnswered(false);
expect(microphoneManager.mute).not.toHaveBeenCalled();
expect(microphoneManager.unmute).not.toHaveBeenCalled();
});
it('should not act on call answer when call is not a configured condition', () => {
it('should not act on call answer when call is not a configured condition', async () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({
@@ -376,8 +377,8 @@ describe('MicrophoneActionsController', () => {
autoUnmuteConditions: [],
});
controller.setCallAnswered(false);
controller.setCallAnswered(true);
await controller.setCallAnswered(false);
await controller.setCallAnswered(true);
expect(microphoneManager.unmute).not.toHaveBeenCalled();
});
+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',