diff --git a/lang/main.json b/lang/main.json
index f93c1602c..078ecb660 100644
--- a/lang/main.json
+++ b/lang/main.json
@@ -211,6 +211,12 @@
"microphonePermission": "Error obtaining microphone permission"
},
"deviceSelection": {
+ "hid": {
+ "callControl": "Call control",
+ "connectedDevices": "Connected devices:",
+ "deleteDevice": "Delete device",
+ "pairDevice": "Pair device"
+ },
"noPermission": "Permission not granted",
"previewUnavailable": "Preview unavailable",
"selectADevice": "Select a device",
diff --git a/package-lock.json b/package-lock.json
index d59579d2a..1d37df93e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -50,6 +50,7 @@
"@types/amplitude-js": "8.16.2",
"@types/audioworklet": "0.0.29",
"@types/w3c-image-capture": "1.0.6",
+ "@types/w3c-web-hid": "1.0.3",
"@vladmandic/human": "2.6.5",
"@vladmandic/human-models": "2.5.9",
"@xmldom/xmldom": "0.7.9",
@@ -6506,6 +6507,11 @@
"@types/webrtc": "*"
}
},
+ "node_modules/@types/w3c-web-hid": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@types/w3c-web-hid/-/w3c-web-hid-1.0.3.tgz",
+ "integrity": "sha512-eTQRkPd2JukZfS9+kRtrBAaTCCb6waGh5X8BJHmH1MiVQPLMYwm4+EvhwFfOo9SDna15o9dFAwmWwN6r/YM53A=="
+ },
"node_modules/@types/webgl-ext": {
"version": "0.0.30",
"resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
@@ -25057,6 +25063,11 @@
"@types/webrtc": "*"
}
},
+ "@types/w3c-web-hid": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@types/w3c-web-hid/-/w3c-web-hid-1.0.3.tgz",
+ "integrity": "sha512-eTQRkPd2JukZfS9+kRtrBAaTCCb6waGh5X8BJHmH1MiVQPLMYwm4+EvhwFfOo9SDna15o9dFAwmWwN6r/YM53A=="
+ },
"@types/webgl-ext": {
"version": "0.0.30",
"resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
diff --git a/package.json b/package.json
index 8d497e9d1..30cd6098a 100644
--- a/package.json
+++ b/package.json
@@ -55,6 +55,7 @@
"@types/amplitude-js": "8.16.2",
"@types/audioworklet": "0.0.29",
"@types/w3c-image-capture": "1.0.6",
+ "@types/w3c-web-hid": "1.0.3",
"@vladmandic/human": "2.6.5",
"@vladmandic/human-models": "2.5.9",
"@xmldom/xmldom": "0.7.9",
diff --git a/react/features/app/middlewares.web.ts b/react/features/app/middlewares.web.ts
index acb6b5ba5..78d9de84b 100644
--- a/react/features/app/middlewares.web.ts
+++ b/react/features/app/middlewares.web.ts
@@ -15,6 +15,7 @@ import '../prejoin/middleware';
import '../remote-control/middleware';
import '../screen-share/middleware';
import '../shared-video/middleware';
+import '../web-hid/middleware';
import '../settings/middleware';
import '../talk-while-muted/middleware';
import '../toolbox/middleware';
diff --git a/react/features/app/reducers.web.ts b/react/features/app/reducers.web.ts
index a9a965534..52bc6886b 100644
--- a/react/features/app/reducers.web.ts
+++ b/react/features/app/reducers.web.ts
@@ -16,5 +16,6 @@ import '../screenshot-capture/reducer';
import '../talk-while-muted/reducer';
import '../virtual-background/reducer';
import '../whiteboard/reducer';
+import '../web-hid/reducer';
import './reducers.any';
diff --git a/react/features/app/types.ts b/react/features/app/types.ts
index 6f4668ee1..078265613 100644
--- a/react/features/app/types.ts
+++ b/react/features/app/types.ts
@@ -77,6 +77,7 @@ import { IVideoQualityPersistedState, IVideoQualityState } from '../video-qualit
import { IVideoSipGW } from '../videosipgw/reducer';
import { IVirtualBackground } from '../virtual-background/reducer';
import { IVisitorsState } from '../visitors/reducer';
+import { IWebHid } from '../web-hid/reducer';
import { IWhiteboardState } from '../whiteboard/reducer';
export interface IStore {
@@ -165,6 +166,7 @@ export interface IReduxState {
'features/videosipgw': IVideoSipGW;
'features/virtual-background': IVirtualBackground;
'features/visitors': IVisitorsState;
+ 'features/web-hid': IWebHid;
'features/whiteboard': IWhiteboardState;
}
diff --git a/react/features/device-selection/components/DeviceHidContainer.web.tsx b/react/features/device-selection/components/DeviceHidContainer.web.tsx
new file mode 100644
index 000000000..839cb02c0
--- /dev/null
+++ b/react/features/device-selection/components/DeviceHidContainer.web.tsx
@@ -0,0 +1,102 @@
+import React, { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useDispatch, useSelector } from 'react-redux';
+import { makeStyles } from 'tss-react/mui';
+
+import Icon from '../../base/icons/components/Icon';
+import { IconTrash } from '../../base/icons/svg';
+import Button from '../../base/ui/components/web/Button';
+import { BUTTON_TYPES } from '../../base/ui/constants.any';
+import { closeHidDevice, requestHidDevice } from '../../web-hid/actions';
+import { getDeviceInfo, shouldRequestHIDDevice } from '../../web-hid/functions';
+
+const useStyles = makeStyles()(() => {
+ return {
+ callControlContainer: {
+ marginTop: '8px',
+ marginBottom: '16px',
+ fontSize: '14px',
+ '> label': {
+ display: 'block',
+ marginBottom: '20px'
+ }
+ },
+ deviceRow: {
+ display: 'flex',
+ justifyContent: 'space-between'
+ },
+ deleteDevice: {
+ cursor: 'pointer',
+ textAlign: 'center'
+ },
+ headerConnectedDevice: {
+ fontWeight: 600
+ },
+ hidContainer: {
+ '> span': {
+ marginLeft: '16px'
+ }
+ }
+ };
+});
+
+/**
+ * Device hid container.
+ *
+ * @param {IProps} props - The props of the component.
+ * @returns {ReactElement}
+ */
+function DeviceHidContainer() {
+ const { t } = useTranslation();
+ const deviceInfo = useSelector(getDeviceInfo);
+ const showRequestDeviceInfo = shouldRequestHIDDevice(deviceInfo);
+ const { classes } = useStyles();
+ const dispatch = useDispatch();
+
+ const onRequestControl = useCallback(() => {
+ dispatch(requestHidDevice());
+ }, [ dispatch ]);
+
+ const onDeleteHid = useCallback(() => {
+ dispatch(closeHidDevice());
+ }, [ dispatch ]);
+
+ return (
+
+
+ {showRequestDeviceInfo && (
+
+ )}
+ {!showRequestDeviceInfo && (
+
+
{t('deviceSelection.hid.connectedDevices')}
+
+ {deviceInfo.device?.productName}
+
+
+
+ )}
+
+ );
+}
+
+export default DeviceHidContainer;
diff --git a/react/features/device-selection/components/DeviceSelection.js b/react/features/device-selection/components/DeviceSelection.js
index 7a21f3a0b..0f004680a 100644
--- a/react/features/device-selection/components/DeviceSelection.js
+++ b/react/features/device-selection/components/DeviceSelection.js
@@ -12,6 +12,7 @@ import logger from '../logger';
import AudioInputPreview from './AudioInputPreview';
import AudioOutputPreview from './AudioOutputPreview';
+import DeviceHidContainer from './DeviceHidContainer.web';
import DeviceSelector from './DeviceSelector';
import VideoInputPreview from './VideoInputPreview';
@@ -76,6 +77,11 @@ export type Props = {
*/
hideAudioOutputSelect: boolean,
+ /**
+ * Whether or not the hid device container should display.
+ */
+ hideDeviceHIDContainer: boolean,
+
/**
* Whether video input preview should be displayed or not.
* (In the case of iOS Safari).
@@ -213,6 +219,7 @@ class DeviceSelection extends AbstractDialogTab {
const {
hideAudioInputPreview,
hideAudioOutputPreview,
+ hideDeviceHIDContainer,
hideVideoInputPreview,
selectedAudioOutputId
} = this.props;
@@ -240,6 +247,8 @@ class DeviceSelection extends AbstractDialogTab {
{ !hideAudioOutputPreview
&& }
+ { !hideDeviceHIDContainer
+ && }
);
diff --git a/react/features/device-selection/functions.web.ts b/react/features/device-selection/functions.web.ts
index 1691e0ff6..e2542300c 100644
--- a/react/features/device-selection/functions.web.ts
+++ b/react/features/device-selection/functions.web.ts
@@ -21,6 +21,7 @@ import {
getUserSelectedMicDeviceId,
getUserSelectedOutputDeviceId
} from '../base/settings/functions.web';
+import { isDeviceHidSupported } from '../web-hid/functions';
/**
* Returns the properties for the device selection dialog from Redux state.
@@ -43,6 +44,7 @@ export function getDeviceSelectionDialogProps(stateful: IStateful, isDisplayedOn
const speakerChangeSupported = JitsiMeetJS.mediaDevices.isDeviceChangeAvailable('output');
const userSelectedCamera = getUserSelectedCameraDeviceId(state);
const userSelectedMic = getUserSelectedMicDeviceId(state);
+ const deviceHidSupported = isDeviceHidSupported();
// When the previews are disabled we don't need multiple audio input support in order to change the mic. This is the
// case for Safari on iOS.
@@ -77,6 +79,7 @@ export function getDeviceSelectionDialogProps(stateful: IStateful, isDisplayedOn
hideAudioInputPreview: disableAudioInputChange || !JitsiMeetJS.isCollectingLocalStats() || disablePreviews,
hideAudioOutputPreview: !speakerChangeSupported || disablePreviews,
hideAudioOutputSelect: !speakerChangeSupported,
+ hideDeviceHIDContainer: !deviceHidSupported,
hideVideoInputPreview: !inputDeviceChangeSupported || disablePreviews,
selectedAudioInputId,
selectedAudioOutputId,
diff --git a/react/features/web-hid/actionTypes.ts b/react/features/web-hid/actionTypes.ts
new file mode 100644
index 000000000..7c1ef1f0c
--- /dev/null
+++ b/react/features/web-hid/actionTypes.ts
@@ -0,0 +1,20 @@
+/**
+ * Action type to INIT_DEVICE.
+ */
+export const INIT_DEVICE = 'INIT_DEVICE';
+
+/**
+ * Action type to CLOSE_HID_DEVICE.
+ */
+export const CLOSE_HID_DEVICE = 'CLOSE_HID_DEVICE';
+
+/**
+ * Action type to REQUEST_HID_DEVICE.
+ */
+export const REQUEST_HID_DEVICE = 'REQUEST_HID_DEVICE';
+
+/**
+ * Action type to UPDATE_DEVICE.
+ */
+export const UPDATE_DEVICE = 'UPDATE_DEVICE';
+
diff --git a/react/features/web-hid/actions.ts b/react/features/web-hid/actions.ts
new file mode 100644
index 000000000..4b9b805f5
--- /dev/null
+++ b/react/features/web-hid/actions.ts
@@ -0,0 +1,51 @@
+import { CLOSE_HID_DEVICE, INIT_DEVICE, REQUEST_HID_DEVICE, UPDATE_DEVICE } from './actionTypes';
+import { IDeviceInfo } from './types';
+
+/**
+ * Action used to init device.
+ *
+ * @param {IDeviceInfo} deviceInfo - Telephony device information.
+ * @returns {Object}
+ */
+export function initDeviceInfo(deviceInfo: IDeviceInfo) {
+ return {
+ type: INIT_DEVICE,
+ deviceInfo
+ };
+}
+
+/**
+ * Request hid device.
+ *
+ * @returns {Object}
+ */
+export function closeHidDevice() {
+ return {
+ type: CLOSE_HID_DEVICE
+ };
+}
+
+/**
+ * Request hid device.
+ *
+ * @param {IDeviceInfo} deviceInfo - Telephony device information.
+ * @returns {Object}
+ */
+export function requestHidDevice() {
+ return {
+ type: REQUEST_HID_DEVICE
+ };
+}
+
+/**
+ * Action used to init device.
+ *
+ * @param {IDeviceInfo} deviceInfo - Telephony device information.
+ * @returns {Object}
+ */
+export function updateDeviceInfo(deviceInfo: IDeviceInfo) {
+ return {
+ type: UPDATE_DEVICE,
+ updates: deviceInfo
+ };
+}
diff --git a/react/features/web-hid/functions.ts b/react/features/web-hid/functions.ts
new file mode 100644
index 000000000..f3b3c9909
--- /dev/null
+++ b/react/features/web-hid/functions.ts
@@ -0,0 +1,121 @@
+import { IReduxState } from '../app/types';
+import { MEDIA_TYPE } from '../base/media/constants';
+import { muteLocal } from '../video-menu/actions.any';
+
+import { updateDeviceInfo } from './actions';
+import { ACTION_HOOK_TYPE_NAME, EVENT_TYPE, IDeviceInfo } from './types';
+import WebHidManager from './webhid-manager';
+
+/**
+ * Attach web hid event listeners.
+ *
+ * @param {Function} initDeviceListener - Init hid device listener.
+ * @param {Function} updateDeviceListener - Update hid device listener.
+ * @returns {void}
+ */
+export function attachHidEventListeners(
+ initDeviceListener: EventListenerOrEventListenerObject,
+ updateDeviceListener: EventListenerOrEventListenerObject
+) {
+ const hidManager = getWebHidInstance();
+
+ if (typeof initDeviceListener === 'function') {
+ hidManager.addEventListener(EVENT_TYPE.INIT_DEVICE, initDeviceListener);
+ }
+ if (typeof updateDeviceListener === 'function') {
+ hidManager.addEventListener(EVENT_TYPE.UPDATE_DEVICE, updateDeviceListener);
+ }
+}
+
+/**
+ * Returns instance of web hid manager.
+ *
+* @returns {WebHidManager} - WebHidManager instance.
+ */
+export function getWebHidInstance(): WebHidManager {
+ const hidManager = WebHidManager.getInstance();
+
+ return hidManager;
+}
+
+/**
+ * Returns root conference state.
+ *
+ * @param {IReduxState} state - Global state.
+ * @returns {Object} Conference state.
+ */
+export const getWebHidState = (state: IReduxState) => state['features/web-hid'];
+
+/**
+ * Returns true if hid is supported.
+ *
+ * @returns {boolean}
+ */
+export function isDeviceHidSupported(): boolean {
+ const hidManager = getWebHidInstance();
+
+ return hidManager.isSupported();
+}
+
+/**
+ * Returns device info from state.
+ *
+ * @param {IReduxState} state - Global state.
+ * @returns {boolean}
+ */
+export function getDeviceInfo(state: IReduxState): IDeviceInfo {
+ const hidState = getWebHidState(state);
+
+ return hidState.deviceInfo;
+}
+
+/**
+ * Handles updating hid device.
+ *
+ * @param {Function} dispatch - Redux dispatch.
+ * @param {Function} customEventData - Custom event data.
+ * @returns {void}
+ */
+export function handleUpdateHidDevice(
+ dispatch: Function,
+ customEventData: CustomEvent<{ actionResult?: { eventName: string; }; deviceInfo: IDeviceInfo; }>
+) {
+ dispatch(updateDeviceInfo(customEventData.detail.deviceInfo));
+
+ if (customEventData.detail?.actionResult?.eventName === ACTION_HOOK_TYPE_NAME.MUTE_SWITCH_ON) {
+ dispatch(muteLocal(true, MEDIA_TYPE.AUDIO));
+ } else if (customEventData.detail?.actionResult?.eventName === ACTION_HOOK_TYPE_NAME.MUTE_SWITCH_OFF) {
+ dispatch(muteLocal(false, MEDIA_TYPE.AUDIO));
+ }
+}
+
+/**
+ * Remove web hid event listeners.
+ *
+ * @param {Function} initDeviceListener - Init hid device listener.
+ * @param {Function} updateDeviceListener - Update hid device listener.
+ * @returns {void}
+ */
+export function removeHidEventListeners(
+ initDeviceListener: EventListenerOrEventListenerObject,
+ updateDeviceListener: EventListenerOrEventListenerObject
+) {
+ const hidManager = getWebHidInstance();
+
+ if (typeof initDeviceListener === 'function') {
+ hidManager.removeEventListener(EVENT_TYPE.INIT_DEVICE, initDeviceListener);
+ }
+ if (typeof updateDeviceListener === 'function') {
+ hidManager.removeEventListener(EVENT_TYPE.UPDATE_DEVICE, updateDeviceListener);
+ }
+}
+
+/**
+ * Returns true if there is no device info provided.
+ *
+ * @param {IDeviceInfo} deviceInfo - Device info state.
+ * @returns {boolean}
+ */
+export function shouldRequestHIDDevice(deviceInfo: IDeviceInfo): boolean {
+ return !deviceInfo || !deviceInfo.device || Object.keys(deviceInfo).length === 0;
+}
diff --git a/react/features/web-hid/logger.ts b/react/features/web-hid/logger.ts
new file mode 100644
index 000000000..f419730cb
--- /dev/null
+++ b/react/features/web-hid/logger.ts
@@ -0,0 +1,3 @@
+import { getLogger } from '../base/logging/functions';
+
+export default getLogger('features/hid');
diff --git a/react/features/web-hid/middleware.ts b/react/features/web-hid/middleware.ts
new file mode 100644
index 000000000..541e2e7a7
--- /dev/null
+++ b/react/features/web-hid/middleware.ts
@@ -0,0 +1,128 @@
+import { IStore } from '../app/types';
+import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app/actionTypes';
+import { SET_AUDIO_MUTED } from '../base/media/actionTypes';
+import { isAudioMuted } from '../base/media/functions';
+import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
+
+import { CLOSE_HID_DEVICE, REQUEST_HID_DEVICE } from './actionTypes';
+import { initDeviceInfo } from './actions';
+import {
+ attachHidEventListeners,
+ getWebHidInstance,
+ handleUpdateHidDevice,
+ isDeviceHidSupported,
+ removeHidEventListeners
+} from './functions';
+import logger from './logger';
+import { COMMANDS, IDeviceInfo } from './types';
+
+/**
+ * A listener for initialising the webhid device.
+ */
+let initDeviceListener: (e: any) => void;
+
+/**
+ * A listener for updating the webhid device.
+ */
+let updateDeviceListener: (e: any) => void;
+
+/**
+ * The redux middleware for {@link WebHid}.
+ *
+ * @param {Store} store - The redux store.
+ * @returns {Function}
+ */
+MiddlewareRegistry.register((store: IStore) => next => async action => {
+ const { dispatch } = store;
+
+ switch (action.type) {
+ case APP_WILL_MOUNT: {
+ const hidManager = getWebHidInstance();
+
+ if (!hidManager.isSupported()) {
+ logger.warn('HID is not supported');
+
+ break;
+ }
+
+ const _initDeviceListener = (e: CustomEvent<{ deviceInfo: IDeviceInfo; }>) =>
+ dispatch(initDeviceInfo(e.detail.deviceInfo));
+ const _updateDeviceListener
+ = (e: CustomEvent<{ actionResult: { eventName: string; }; deviceInfo: IDeviceInfo; }>) =>
+ handleUpdateHidDevice(dispatch, e);
+
+
+ initDeviceListener = _initDeviceListener;
+ updateDeviceListener = _updateDeviceListener;
+
+ hidManager.listenToConnectedHid();
+ attachHidEventListeners(initDeviceListener, updateDeviceListener);
+
+ break;
+ }
+ case APP_WILL_UNMOUNT: {
+ const hidManager = getWebHidInstance();
+
+ if (!isDeviceHidSupported()) {
+ break;
+ }
+
+ removeHidEventListeners(initDeviceListener, updateDeviceListener);
+ hidManager.close();
+
+ break;
+ }
+ case CLOSE_HID_DEVICE: {
+ const hidManager = getWebHidInstance();
+
+ // cleanup event handlers when hid device is removed from Settings.
+ removeHidEventListeners(initDeviceListener, updateDeviceListener);
+
+ hidManager.close();
+
+ break;
+ }
+ case REQUEST_HID_DEVICE: {
+ const hidManager = getWebHidInstance();
+
+ const availableDevices = await hidManager.requestHidDevices();
+
+ if (!availableDevices || !availableDevices.length) {
+ logger.info('HID device not available');
+ break;
+ }
+
+ const _initDeviceListener = (e: CustomEvent<{ deviceInfo: IDeviceInfo; }>) =>
+ dispatch(initDeviceInfo(e.detail.deviceInfo));
+ const _updateDeviceListener
+ = (e: CustomEvent<{ actionResult: { eventName: string; }; deviceInfo: IDeviceInfo; }>) => {
+ handleUpdateHidDevice(dispatch, e);
+ };
+
+ initDeviceListener = _initDeviceListener;
+ updateDeviceListener = _updateDeviceListener;
+
+ attachHidEventListeners(initDeviceListener, updateDeviceListener);
+ await hidManager.listenToConnectedHid();
+
+ // sync headset to mute if participant is already muted.
+ if (isAudioMuted(store.getState())) {
+ hidManager.sendDeviceReport({ command: COMMANDS.MUTE_ON });
+ }
+
+ break;
+ }
+ case SET_AUDIO_MUTED: {
+ const hidManager = getWebHidInstance();
+
+ if (!isDeviceHidSupported()) {
+ break;
+ }
+
+ hidManager.sendDeviceReport({ command: action.muted ? COMMANDS.MUTE_ON : COMMANDS.MUTE_OFF });
+ break;
+ }
+ }
+
+ return next(action);
+});
diff --git a/react/features/web-hid/reducer.ts b/react/features/web-hid/reducer.ts
new file mode 100644
index 000000000..f1c994a23
--- /dev/null
+++ b/react/features/web-hid/reducer.ts
@@ -0,0 +1,43 @@
+import ReducerRegistry from '../base/redux/ReducerRegistry';
+
+import { CLOSE_HID_DEVICE, INIT_DEVICE, UPDATE_DEVICE } from './actionTypes';
+import { IDeviceInfo } from './types';
+
+/**
+ * The initial state of the web-hid feature.
+*/
+const DEFAULT_STATE = {
+ deviceInfo: {} as IDeviceInfo
+};
+
+export interface IWebHid {
+ deviceInfo: IDeviceInfo;
+}
+
+
+ReducerRegistry.register(
+'features/web-hid',
+(state: IWebHid = DEFAULT_STATE, action): IWebHid => {
+ switch (action.type) {
+ case INIT_DEVICE:
+ return {
+ ...state,
+ deviceInfo: action.deviceInfo
+ };
+ case UPDATE_DEVICE:
+ return {
+ ...state,
+ deviceInfo: {
+ ...state.deviceInfo,
+ ...action.updates
+ }
+ };
+ case CLOSE_HID_DEVICE:
+ return {
+ ...state,
+ deviceInfo: DEFAULT_STATE.deviceInfo
+ };
+ default:
+ return state;
+ }
+});
diff --git a/react/features/web-hid/types.ts b/react/features/web-hid/types.ts
new file mode 100644
index 000000000..9b8c5b847
--- /dev/null
+++ b/react/features/web-hid/types.ts
@@ -0,0 +1,44 @@
+export const EVENT_TYPE = {
+ INIT_DEVICE: 'INIT_DEVICE',
+ UPDATE_DEVICE: 'UPDATE_DEVICE'
+};
+
+export const HOOK_STATUS = {
+ ON: 'on',
+ OFF: 'off'
+};
+
+export const COMMANDS = {
+ ON_HOOK: 'onHook',
+ OFF_HOOK: 'offHook',
+ MUTE_OFF: 'muteOff',
+ MUTE_ON: 'muteOn',
+ ON_RING: 'onRing',
+ OFF_RING: 'offRing',
+ ON_HOLD: 'onHold',
+ OFF_HOLD: 'offHold'
+};
+
+export const INPUT_REPORT_EVENT_NAME = {
+ ON_DEVICE_HOOK_SWITCH: 'ondevicehookswitch',
+ ON_DEVICE_MUTE_SWITCH: 'ondevicemuteswitch'
+};
+
+export const ACTION_HOOK_TYPE_NAME = {
+ HOOK_SWITCH_ON: 'HOOK_SWITCH_ON',
+ HOOK_SWITCH_OFF: 'HOOK_SWITCH_OFF',
+ MUTE_SWITCH_ON: 'MUTE_SWITCH_ON',
+ MUTE_SWITCH_OFF: 'MUTE_SWITCH_OFF',
+ VOLUME_CHANGE_UP: 'VOLUME_CHANGE_UP',
+ VOLUME_CHANGE_DOWN: 'VOLUME_CHANGE_DOWN'
+};
+
+export interface IDeviceInfo {
+
+ // @ts-ignore
+ device: HIDDevice;
+ hold: boolean;
+ hookStatus: string;
+ muted: boolean;
+ ring: boolean;
+}
diff --git a/react/features/web-hid/utils.ts b/react/features/web-hid/utils.ts
new file mode 100644
index 000000000..18b044e89
--- /dev/null
+++ b/react/features/web-hid/utils.ts
@@ -0,0 +1,52 @@
+/**
+ * Telephony usage actions based on HID Usage tables for Universal Serial Bus (page 112.).
+ *
+ */
+export const TELEPHONY_DEVICE_USAGE_PAGE = 11;
+
+/** Telephony usages
+ * - used to parse HIDDevice UsageId collections
+ ** - outputReports has mute and offHook
+ ** - inputReports exists hookSwitch and phoneMute.
+ **/
+export const DEVICE_USAGE = {
+ /* outputReports. */
+ mute: {
+ usageId: 0x080009,
+ usageName: 'Mute'
+ },
+ offHook: {
+ usageId: 0x080017,
+ usageName: 'Off Hook'
+ },
+ ring: {
+ usageId: 0x080018,
+ usageName: 'Ring'
+ },
+ hold: {
+ usageId: 0x080020,
+ usageName: 'Hold'
+ },
+
+ /* inputReports. */
+ hookSwitch: {
+ usageId: 0x0b0020,
+ usageName: 'Hook Switch'
+ },
+ phoneMute: {
+ usageId: 0x0b002f,
+ usageName: 'Phone Mute'
+ }
+};
+
+/**
+ * Filter with telephony devices based on HID Usage tables for Universal Serial Bus (page 17).
+ *
+ * @type {{ filters: { usagePage: string }; exclusionFilters: {}; }}
+ */
+export const requestTelephonyHID = {
+ filters: [ {
+ usagePage: TELEPHONY_DEVICE_USAGE_PAGE
+ } ],
+ exclusionFilters: []
+};
diff --git a/react/features/web-hid/webhid-manager.ts b/react/features/web-hid/webhid-manager.ts
new file mode 100644
index 000000000..da831706a
--- /dev/null
+++ b/react/features/web-hid/webhid-manager.ts
@@ -0,0 +1,1019 @@
+import logger from './logger';
+import {
+ ACTION_HOOK_TYPE_NAME,
+ COMMANDS,
+ EVENT_TYPE,
+ HOOK_STATUS,
+ IDeviceInfo,
+ INPUT_REPORT_EVENT_NAME
+} from './types';
+import {
+ DEVICE_USAGE,
+ TELEPHONY_DEVICE_USAGE_PAGE,
+ requestTelephonyHID
+} from './utils';
+
+/**
+ * WebHID manager that incorporates all hid specific logic.
+ *
+ * @class WebHidManager
+ */
+export default class WebHidManager extends EventTarget {
+ hidSupport: boolean;
+ deviceInfo: IDeviceInfo;
+ availableDevices: HIDDevice[];
+ isParseDescriptorsSuccess: boolean;
+ outputEventGenerators: { [key: string]: Function; };
+ deviceCommand = {
+ outputReport: {
+ mute: {
+ reportId: 0,
+ usageOffset: -1
+ },
+ offHook: {
+ reportId: 0,
+ usageOffset: -1
+ },
+ ring: {
+ reportId: 0,
+ usageOffset: 0
+ },
+ hold: {
+ reportId: 0,
+ usageOffset: 0
+ }
+ },
+ inputReport: {
+ hookSwitch: {
+ reportId: 0,
+ usageOffset: -1,
+ isAbsolute: false
+ },
+ phoneMute: {
+ reportId: 0,
+ usageOffset: -1,
+ isAbsolute: false
+ }
+ }
+ };
+
+ private static instance: WebHidManager;
+
+ /**
+ * WebHidManager getInstance.
+ *
+ * @static
+ * @returns {WebHidManager} - WebHidManager instance.
+ */
+ static getInstance(): WebHidManager {
+ if (!this.instance) {
+ this.instance = new WebHidManager();
+ }
+
+ return this.instance;
+ }
+
+ /**
+ * Creates an instance of WebHidManager.
+ *
+ */
+ constructor() {
+ super();
+
+ this.deviceInfo = {} as IDeviceInfo;
+ this.hidSupport = this.isSupported();
+ this.availableDevices = [];
+ this.isParseDescriptorsSuccess = false;
+ this.outputEventGenerators = {};
+ }
+
+ /**
+ * Check support of hid in navigator.
+ * - experimental API in Chrome.
+ *
+ * @returns {boolean} - True if supported, otherwise false.
+ */
+ isSupported(): boolean {
+ // @ts-ignore
+ return !(!window.navigator.hid || !window.navigator.hid.requestDevice);
+ }
+
+ /**
+ * Handler for requesting telephony hid devices.
+ *
+ * @returns {HIDDevice[]|null}
+ */
+ async requestHidDevices() {
+ if (!this.hidSupport) {
+ logger.warn('The WebHID API is NOT supported!');
+
+ return null;
+ }
+
+ if (this.deviceInfo?.device && this.deviceInfo.device.opened) {
+ await this.close();
+ }
+
+ // @ts-ignore
+ const devices = await navigator.hid.requestDevice(requestTelephonyHID);
+
+ if (!devices || !devices.length) {
+ logger.warn('No HID devices selected.');
+
+ return false;
+ }
+
+ this.availableDevices = devices;
+
+ return devices;
+ }
+
+ /**
+ * Handler for listen to already connected hid.
+ *
+ * @returns {void}
+ */
+ async listenToConnectedHid() {
+ const devices = await this.loadPairedDevices();
+
+ if (!devices || !devices.length) {
+ logger.warn('No hid device found.');
+
+ return;
+ }
+
+ const telephonyDevice = this.getTelephonyDevice(devices);
+
+ if (!telephonyDevice) {
+ logger.warn('No HID device to request');
+
+ return;
+ }
+
+ await this.open(telephonyDevice);
+
+ // restore the default state of hook and mic LED
+ this.resetDeviceState();
+
+ // switch headsets to OFF_HOOK for mute/unmute commands
+ this.sendDeviceReport({ command: COMMANDS.OFF_HOOK });
+ }
+
+ /**
+ * Get first telephony device from availableDevices.
+ *
+ * @param {HIDDevice[]} availableDevices -.
+ * @returns {HIDDevice} -.
+ */
+ private getTelephonyDevice(availableDevices: HIDDevice[]) {
+ if (!availableDevices || !availableDevices.length) {
+ logger.warn('No HID device to request');
+
+ return undefined;
+ }
+
+ return availableDevices?.find(device => this.findTelephonyCollectionInfo(device.collections));
+ }
+
+ /**
+ * Find telephony collection info from a list of collection infos.
+ *
+ * @private
+ * @param {HIDCollectionInfo[]} deviceCollections -.
+ * @returns {HIDCollectionInfo} - Hid collection info.
+ */
+ private findTelephonyCollectionInfo(deviceCollections: HIDCollectionInfo[]) {
+ return deviceCollections?.find(
+ (collection: HIDCollectionInfo) => collection.usagePage === TELEPHONY_DEVICE_USAGE_PAGE
+ );
+ }
+
+ /**
+ * Open the hid device and start listening to inputReport events.
+ *
+ * @param {HIDDevice} telephonyDevice -.
+ * @returns {void} -.
+ */
+ private async open(telephonyDevice: HIDDevice) {
+ try {
+ this.deviceInfo = { device: telephonyDevice } as IDeviceInfo;
+
+ if (!this.deviceInfo || !this.deviceInfo.device) {
+ logger.warn('no HID device found');
+
+ return;
+ }
+
+ if (!this.deviceInfo.device.opened) {
+ await this.deviceInfo.device.open();
+ }
+
+ this.isParseDescriptorsSuccess = await this.parseDeviceDescriptors(this.deviceInfo.device);
+
+ if (!this.isParseDescriptorsSuccess) {
+ logger.warn('Failed to parse webhid');
+
+ return;
+ }
+
+ this.dispatchEvent(new CustomEvent(EVENT_TYPE.INIT_DEVICE, { detail: {
+ deviceInfo: {
+ ...this.deviceInfo
+ } as IDeviceInfo } }));
+
+ // listen for input reports by registering an oninputreport event listener
+ this.deviceInfo.device.oninputreport = await this.handleInputReport.bind(this);
+
+ this.resetDeviceState();
+ } catch (e) {
+ logger.error(`Error content open device:${e}`);
+ }
+ }
+
+ /**
+ * Close device and reset state.
+ *
+ * @returns {void}.
+ */
+ async close() {
+ try {
+ await this.resetDeviceState();
+
+ if (this.availableDevices) {
+ logger.info('clear available devices list');
+ this.availableDevices = [];
+ }
+
+ if (!this.deviceInfo) {
+ return;
+ }
+
+ if (this.deviceInfo?.device?.opened) {
+ await this.deviceInfo.device.close();
+ }
+
+ if (this.deviceInfo.device) {
+ this.deviceInfo.device.oninputreport = null;
+ }
+ this.deviceInfo = {} as IDeviceInfo;
+ } catch (e) {
+ logger.error(e);
+ }
+ }
+
+ /**
+ * Get paired hid devices.
+ *
+ * @returns {HIDDevice[]}
+ */
+ async loadPairedDevices() {
+ try {
+ // @ts-ignore
+ const devices = await navigator.hid.getDevices();
+
+ this.availableDevices = devices;
+
+ return devices;
+ } catch (e) {
+ logger.error('loadPairedDevices error:', e);
+ }
+ }
+
+ /**
+ * Parse device descriptors - input and output reports.
+ *
+ * @param {HIDDevice} device -.
+ * @returns {boolean} - True if descriptors have been parsed with success.
+ */
+ parseDeviceDescriptors(device: HIDDevice) {
+ try {
+ this.outputEventGenerators = {};
+
+ if (!device || !device.collections) {
+ logger.error('Undefined device collection');
+
+ return false;
+ }
+
+ const telephonyCollection = this.findTelephonyCollectionInfo(device.collections);
+
+ if (!telephonyCollection || Object.keys(telephonyCollection).length === 0) {
+ logger.error('No telephony collection');
+
+ return false;
+ }
+
+ if (telephonyCollection.inputReports) {
+ if (!this.parseInputReports(telephonyCollection.inputReports)) {
+ logger.warn('parse inputReports failed');
+
+ return false;
+ }
+ logger.warn('parse inputReports success');
+
+ }
+
+ if (telephonyCollection.outputReports) {
+ if (!this.parseOutputReports(telephonyCollection.outputReports)) {
+ logger.warn('parse outputReports failed');
+
+ return false;
+ }
+ logger.warn('parse outputReports success');
+
+ return true;
+
+ }
+
+ logger.warn('parseDeviceDescriptors: returns false, end');
+
+ return false;
+ } catch (e) {
+ logger.error(`parseDeviceDescriptors error:${JSON.stringify(e, null, ' ')}`);
+
+ return false;
+ }
+ }
+
+ /**
+ * HandleInputReport.
+ *
+ * @param {HIDInputReportEvent} event -.
+ * @returns {void} -.
+ */
+ handleInputReport(event: HIDInputReportEvent) {
+ try {
+ const { data, device, reportId } = event;
+
+ if (reportId === 0) {
+ logger.warn('handleInputReport: ignore invalid reportId');
+
+ return;
+ }
+
+ const inputReport = this.deviceCommand.inputReport;
+
+ logger.warn(`current inputReport:${JSON.stringify(inputReport, null, ' ')}, reporId: ${reportId}`);
+ if (reportId !== inputReport.hookSwitch.reportId && reportId !== inputReport.phoneMute.reportId) {
+ logger.warn('handleInputReport:ignore unknown reportId');
+
+ return;
+ }
+
+ let hookStatusChange = false;
+ let muteStatusChange = false;
+
+ const reportData = new Uint8Array(data.buffer);
+ const needReply = true;
+
+ if (reportId === inputReport.hookSwitch.reportId) {
+ const item = inputReport.hookSwitch;
+ const byteIndex = Math.trunc(item.usageOffset / 8);
+ const bitPosition = item.usageOffset % 8;
+ // eslint-disable-next-line no-bitwise
+ const usageOn = (data.getUint8(byteIndex) & (0x01 << bitPosition)) !== 0;
+
+ logger.warn('recv hookSwitch ', usageOn ? HOOK_STATUS.OFF : HOOK_STATUS.ON);
+ if (inputReport.hookSwitch.isAbsolute) {
+ if (this.deviceInfo.hookStatus === HOOK_STATUS.ON && usageOn) {
+ this.deviceInfo.hookStatus = HOOK_STATUS.OFF;
+ hookStatusChange = true;
+ } else if (this.deviceInfo.hookStatus === HOOK_STATUS.OFF && !usageOn) {
+ this.deviceInfo.hookStatus = HOOK_STATUS.ON;
+ hookStatusChange = true;
+ }
+ } else if (usageOn) {
+ this.deviceInfo.hookStatus = this.deviceInfo.hookStatus === HOOK_STATUS.OFF
+ ? HOOK_STATUS.ON : HOOK_STATUS.OFF;
+ hookStatusChange = true;
+ }
+ }
+
+ if (reportId === inputReport.phoneMute.reportId) {
+ const item = inputReport.phoneMute;
+ const byteIndex = Math.trunc(item.usageOffset / 8);
+ const bitPosition = item.usageOffset % 8;
+ // eslint-disable-next-line no-bitwise
+ const usageOn = (data.getUint8(byteIndex) & (0x01 << bitPosition)) !== 0;
+
+ logger.warn('recv phoneMute ', usageOn ? HOOK_STATUS.ON : HOOK_STATUS.OFF);
+ if (inputReport.phoneMute.isAbsolute) {
+ if (this.deviceInfo.muted !== usageOn) {
+ this.deviceInfo.muted = usageOn;
+ muteStatusChange = true;
+ }
+ } else if (usageOn) {
+ this.deviceInfo.muted = !this.deviceInfo.muted;
+ muteStatusChange = true;
+ }
+ }
+
+ const inputReportData = {
+ productName: device.productName,
+ reportId: this.getHexByte(reportId),
+ reportData,
+ eventName: '',
+ isMute: false,
+ hookStatus: ''
+ };
+
+ if (hookStatusChange) {
+ // Answer key state change
+ inputReportData.eventName = INPUT_REPORT_EVENT_NAME.ON_DEVICE_HOOK_SWITCH;
+ inputReportData.hookStatus = this.deviceInfo.hookStatus;
+ logger.warn(`hook status change: ${this.deviceInfo.hookStatus}`);
+ }
+
+ if (muteStatusChange) {
+ // Mute key state change
+ inputReportData.eventName = INPUT_REPORT_EVENT_NAME.ON_DEVICE_MUTE_SWITCH;
+ inputReportData.isMute = this.deviceInfo.muted;
+ logger.warn(`mute status change: ${this.deviceInfo.muted}`);
+ }
+
+ const actionResult = this.extractActionResult(inputReportData);
+
+ this.dispatchEvent(
+ new CustomEvent(EVENT_TYPE.UPDATE_DEVICE, {
+ detail: {
+ actionResult,
+ deviceInfo: this.deviceInfo
+ }
+ })
+ );
+
+ logger.warn(
+ `hookStatusChange=${
+ hookStatusChange
+ }, muteStatusChange=${
+ muteStatusChange
+ }, needReply=${
+ needReply}`
+ );
+ if (needReply && (hookStatusChange || muteStatusChange)) {
+ let newOffHook;
+
+ if (this.deviceInfo.hookStatus === HOOK_STATUS.OFF) {
+ newOffHook = true;
+ } else if (this.deviceInfo.hookStatus === HOOK_STATUS.ON) {
+ newOffHook = false;
+ } else {
+ logger.warn('Invalid hook status');
+
+ return;
+ }
+ this.sendReplyReport(reportId, newOffHook, this.deviceInfo.muted);
+ } else {
+ logger.warn(`Not sending reply report: needReply ${needReply},
+ hookStatusChange: ${hookStatusChange}, muteStatusChange: ${muteStatusChange}`);
+ }
+ } catch (e) {
+ logger.error(e);
+ }
+ }
+
+ /**
+ * Extract action result.
+ *
+ * @private
+ * @param {*} data -.
+ * @returns {{eventName: string}} - EventName.
+ */
+ private extractActionResult(data: any) {
+ switch (data.eventName) {
+ case INPUT_REPORT_EVENT_NAME.ON_DEVICE_HOOK_SWITCH:
+ return {
+ eventName: data.hookStatus === HOOK_STATUS.ON
+ ? ACTION_HOOK_TYPE_NAME.HOOK_SWITCH_ON : ACTION_HOOK_TYPE_NAME.HOOK_SWITCH_OFF
+ };
+ case INPUT_REPORT_EVENT_NAME.ON_DEVICE_MUTE_SWITCH:
+ return {
+ eventName: data.isMute ? ACTION_HOOK_TYPE_NAME.MUTE_SWITCH_ON : ACTION_HOOK_TYPE_NAME.MUTE_SWITCH_OFF
+ };
+ case 'ondevicevolumechange':
+ return {
+ eventName: data.volumeStatus === 'up'
+ ? ACTION_HOOK_TYPE_NAME.VOLUME_CHANGE_UP : ACTION_HOOK_TYPE_NAME.VOLUME_CHANGE_DOWN
+ };
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Reset device state.
+ *
+ * @returns {void} -.
+ */
+ resetDeviceState() {
+ if (!this.deviceInfo || !this.deviceInfo.device || !this.deviceInfo.device.opened) {
+ return;
+ }
+
+ this.deviceInfo.hookStatus = HOOK_STATUS.ON;
+ this.deviceInfo.muted = false;
+ this.deviceInfo.ring = false;
+ this.deviceInfo.hold = false;
+
+ this.sendDeviceReport({ command: COMMANDS.ON_HOOK });
+ this.sendDeviceReport({ command: COMMANDS.MUTE_OFF });
+ }
+
+ /**
+ * Parse input reports.
+ *
+ * @param {HIDReportInfo[]} inputReports -.
+ * @returns {void} -.
+ */
+ private parseInputReports(inputReports: HIDReportInfo[]) {
+ inputReports.forEach(report => {
+ if (!report || !report.items?.length || report.reportId === undefined) {
+ return;
+ }
+
+ let usageOffset = 0;
+
+ report.items.forEach((item: HIDReportItem) => {
+ if (
+ item.usages === undefined
+ || item.reportSize === undefined
+ || item.reportCount === undefined
+ || item.isAbsolute === undefined
+ ) {
+ logger.warn('parseInputReports invalid parameters!');
+
+ return;
+ }
+
+ const reportSize = item.reportSize ?? 0;
+ const reportId = report.reportId ?? 0;
+
+ item.usages.forEach((usage: number, i: number) => {
+ switch (usage) {
+ case DEVICE_USAGE.hookSwitch.usageId:
+ this.deviceCommand.inputReport.hookSwitch = {
+ reportId,
+ usageOffset: usageOffset + (i * reportSize),
+ isAbsolute: item.isAbsolute ?? false
+ };
+ break;
+ case DEVICE_USAGE.phoneMute.usageId:
+ this.deviceCommand.inputReport.phoneMute = {
+ reportId,
+ usageOffset: usageOffset + (i * reportSize),
+ isAbsolute: item.isAbsolute ?? false
+ };
+ break;
+ default:
+ break;
+ }
+ });
+
+ usageOffset += item.reportCount * item.reportSize;
+ });
+ });
+
+ if (!this.deviceCommand.inputReport.phoneMute || !this.deviceCommand.inputReport.hookSwitch) {
+ logger.warn('parseInputReports - no phoneMute or hookSwitch. Skip. Returning false');
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Parse output reports.
+ *
+ * @private
+ * @param {HIDReportInfo[]} outputReports -.
+ * @returns {void} -.
+ */
+ private parseOutputReports(outputReports: HIDReportInfo[]) {
+ outputReports.forEach((report: HIDReportInfo) => {
+ if (!report || !report.items?.length || report.reportId === undefined) {
+ return;
+ }
+
+ let usageOffset = 0;
+ const usageOffsetMap: Map = new Map();
+
+ report.items.forEach(item => {
+ if (item.usages === undefined || item.reportSize === undefined || item.reportCount === undefined) {
+ logger.warn('parseOutputReports invalid parameters!');
+
+ return;
+ }
+
+ const reportSize = item.reportSize ?? 0;
+ const reportId = report.reportId ?? 0;
+
+ item.usages.forEach((usage: number, i: number) => {
+ switch (usage) {
+ case DEVICE_USAGE.mute.usageId:
+ this.deviceCommand.outputReport.mute = {
+ reportId,
+ usageOffset: usageOffset + (i * reportSize)
+ };
+ usageOffsetMap.set(usage, usageOffset + (i * reportSize));
+ break;
+ case DEVICE_USAGE.offHook.usageId:
+ this.deviceCommand.outputReport.offHook = {
+ reportId,
+ usageOffset: usageOffset + (i * reportSize)
+ };
+ usageOffsetMap.set(usage, usageOffset + (i * reportSize));
+ break;
+ case DEVICE_USAGE.ring.usageId:
+ this.deviceCommand.outputReport.ring = {
+ reportId,
+ usageOffset: usageOffset + (i * reportSize)
+ };
+ usageOffsetMap.set(usage, usageOffset + (i * reportSize));
+ break;
+ case DEVICE_USAGE.hold.usageId:
+ this.deviceCommand.outputReport.hold = {
+ reportId,
+ usageOffset: usageOffset = i * reportSize
+ };
+ usageOffsetMap.set(usage, usageOffset + (i * reportSize));
+ break;
+ default:
+ break;
+ }
+ });
+
+ usageOffset += item.reportCount * item.reportSize;
+ });
+
+ const reportLength = usageOffset;
+
+ for (const [ usage, offset ] of usageOffsetMap) {
+ this.outputEventGenerators[usage] = (val: number) => {
+ const reportData = new Uint8Array(reportLength / 8);
+
+ if (offset >= 0 && val) {
+ const byteIndex = Math.trunc(offset / 8);
+ const bitPosition = offset % 8;
+
+ // eslint-disable-next-line no-bitwise
+ reportData[byteIndex] = 1 << bitPosition;
+ }
+
+ return reportData;
+ };
+ }
+ });
+
+ let hook, mute, ring;
+
+ for (const item in this.outputEventGenerators) {
+ if (Object.prototype.hasOwnProperty.call(this.outputEventGenerators, item)) {
+ let newItem = this.getHexByte(item);
+
+ newItem = `0x0${newItem}`;
+ if (DEVICE_USAGE.mute.usageId === Number(newItem)) {
+ mute = this.outputEventGenerators[DEVICE_USAGE.mute.usageId];
+ } else if (DEVICE_USAGE.offHook.usageId === Number(newItem)) {
+ hook = this.outputEventGenerators[DEVICE_USAGE.offHook.usageId];
+ } else if (DEVICE_USAGE.ring.usageId === Number(newItem)) {
+ ring = this.outputEventGenerators[DEVICE_USAGE.ring.usageId];
+ }
+ }
+ }
+ if (!mute && !ring && !hook) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Send device report.
+ *
+ * @param {{ command: string }} data -.
+ * @returns {void} -.
+ */
+ async sendDeviceReport(data: { command: string; }) {
+ if (!data || !data.command || !this.deviceInfo
+ || !this.deviceInfo.device || !this.deviceInfo.device.opened || !this.isParseDescriptorsSuccess) {
+ logger.warn('There are currently non-compliant conditions');
+
+ return;
+ }
+
+ logger.warn(`sendDeviceReport data.command: ${data.command}`);
+
+ if (data.command === COMMANDS.MUTE_ON || data.command === COMMANDS.MUTE_OFF) {
+ if (!this.outputEventGenerators[DEVICE_USAGE.mute.usageId]) {
+ logger.warn('current no parse mute event');
+
+ return;
+ }
+ } else if (data.command === COMMANDS.ON_HOOK || data.command === COMMANDS.OFF_HOOK) {
+ if (!this.outputEventGenerators[DEVICE_USAGE.offHook.usageId]) {
+ logger.warn('current no parse offHook event');
+
+ return;
+ }
+ } else if (data.command === COMMANDS.ON_RING || data.command === COMMANDS.OFF_RING) {
+ if (!this.outputEventGenerators[DEVICE_USAGE.ring.usageId]) {
+ logger.warn('current no parse ring event');
+
+ return;
+ }
+ }
+
+ let oldOffHook;
+ let newOffHook;
+ let newMuted;
+ let newRing;
+ let newHold;
+ let offHookReport;
+ let muteReport;
+ let ringReport;
+ let holdReport;
+ let reportData = new Uint8Array();
+
+ const reportId = this.matchReportId(data.command);
+
+ if (reportId === 0) {
+ logger.warn(`Unsupported command ${data.command}`);
+
+ return;
+ }
+
+ /* keep old status. */
+ const oldMuted = this.deviceInfo.muted;
+
+ if (this.deviceInfo.hookStatus === HOOK_STATUS.OFF) {
+ oldOffHook = true;
+ } else if (this.deviceInfo.hookStatus === HOOK_STATUS.ON) {
+ oldOffHook = false;
+ } else {
+ logger.warn('Invalid hook status');
+
+ return;
+ }
+
+ const oldRing = this.deviceInfo.ring;
+ const oldHold = this.deviceInfo.hold;
+
+ logger.warn(
+ `send device command: old_hook=${oldOffHook}, old_muted=${oldMuted}, old_ring=${oldRing}`
+ );
+
+ /* get new status. */
+ switch (data.command) {
+ case COMMANDS.MUTE_ON:
+ newMuted = true;
+ break;
+ case COMMANDS.MUTE_OFF:
+ newMuted = false;
+ break;
+ case COMMANDS.ON_HOOK:
+ newOffHook = false;
+ break;
+ case COMMANDS.OFF_HOOK:
+ newOffHook = true;
+ break;
+ case COMMANDS.ON_RING:
+ newRing = true;
+ break;
+ case COMMANDS.OFF_RING:
+ newRing = false;
+ break;
+ case COMMANDS.ON_HOLD:
+ newHold = true;
+ break;
+ case COMMANDS.OFF_HOLD:
+ newHold = false;
+ break;
+ default:
+ logger.info(`Unknown command ${data.command}`);
+
+ return;
+ }
+ logger.warn(
+ `send device command: new_hook = ${newOffHook}, new_muted = ${newMuted},
+ new_ring = ${newRing} new_hold = ${newHold}`
+ );
+
+ if (this.outputEventGenerators[DEVICE_USAGE.mute.usageId]) {
+ if (newMuted === undefined) {
+ muteReport = this.outputEventGenerators[DEVICE_USAGE.mute.usageId](oldMuted);
+ } else {
+ muteReport = this.outputEventGenerators[DEVICE_USAGE.mute.usageId](newMuted);
+ }
+ }
+
+ if (this.outputEventGenerators[DEVICE_USAGE.offHook.usageId]) {
+ if (newOffHook === undefined) {
+ offHookReport = this.outputEventGenerators[DEVICE_USAGE.offHook.usageId](oldOffHook);
+ } else {
+ offHookReport = this.outputEventGenerators[DEVICE_USAGE.offHook.usageId](newOffHook);
+ }
+ }
+
+ if (this.outputEventGenerators[DEVICE_USAGE.ring.usageId]) {
+ if (newRing === undefined) {
+ ringReport = this.outputEventGenerators[DEVICE_USAGE.ring.usageId](oldRing);
+ } else {
+ ringReport = this.outputEventGenerators[DEVICE_USAGE.ring.usageId](newRing);
+ }
+ }
+
+ if (this.outputEventGenerators[DEVICE_USAGE.hold.usageId]) {
+ holdReport = this.outputEventGenerators[DEVICE_USAGE.hold.usageId](oldHold);
+ }
+
+ if (reportId === this.deviceCommand.outputReport.mute.reportId) {
+ reportData = new Uint8Array(muteReport);
+ }
+
+ if (reportId === this.deviceCommand.outputReport.offHook.reportId) {
+ reportData = new Uint8Array(offHookReport);
+ }
+
+ if (reportId === this.deviceCommand.outputReport.ring.reportId) {
+ reportData = new Uint8Array(ringReport);
+ }
+
+ if (reportId === this.deviceCommand.outputReport.hold.reportId) {
+ reportData = new Uint8Array(holdReport);
+ }
+
+ logger.warn(`[sendDeviceReport] send device command (before call webhid API)
+ ${data.command}: reportId=${reportId}, reportData=${reportData}`);
+ logger.warn(`reportData is ${JSON.stringify(reportData, null, ' ')}`);
+ await this.deviceInfo.device.sendReport(reportId, reportData);
+
+ /* update new status. */
+ this.updateDeviceStatus(data);
+ }
+
+ /**
+ * Update device status.
+ *
+ * @private
+ * @param {{ command: string; }} data -.
+ * @returns {void}
+ */
+ private updateDeviceStatus(data: { command: string; }) {
+ switch (data.command) {
+ case COMMANDS.MUTE_ON:
+ this.deviceInfo.muted = true;
+ break;
+ case COMMANDS.MUTE_OFF:
+ this.deviceInfo.muted = false;
+ break;
+ case COMMANDS.ON_HOOK:
+ this.deviceInfo.hookStatus = HOOK_STATUS.ON;
+ break;
+ case COMMANDS.OFF_HOOK:
+ this.deviceInfo.hookStatus = HOOK_STATUS.OFF;
+ break;
+ case COMMANDS.ON_RING:
+ this.deviceInfo.ring = true;
+ break;
+ case COMMANDS.OFF_RING:
+ this.deviceInfo.ring = false;
+ break;
+ case COMMANDS.ON_HOLD:
+ this.deviceInfo.hold = true;
+ break;
+ case 'offHold':
+ this.deviceInfo.hold = false;
+ break;
+ default:
+ logger.warn(`Unknown command ${data.command}`);
+ break;
+ }
+ logger.warn(
+ `[updateDeviceStatus] device status after send command: hook=${this.deviceInfo.hookStatus},
+ muted=${this.deviceInfo.muted}, ring=${this.deviceInfo.ring}`
+ );
+ }
+
+ /**
+ * Math given command with known commands.
+ *
+ * @private
+ * @param {string} command -.
+ * @returns {number} ReportId.
+ */
+ private matchReportId(command: string) {
+ switch (command) {
+ case COMMANDS.MUTE_ON:
+ case COMMANDS.MUTE_OFF:
+ return this.deviceCommand.outputReport.mute.reportId;
+ case COMMANDS.ON_HOOK:
+ case COMMANDS.OFF_HOOK:
+ return this.deviceCommand.outputReport.offHook.reportId;
+ case COMMANDS.ON_RING:
+ case COMMANDS.OFF_RING:
+ return this.deviceCommand.outputReport.ring.reportId;
+ case COMMANDS.ON_HOLD:
+ case COMMANDS.OFF_HOLD:
+ return this.deviceCommand.outputReport.hold.reportId;
+ default:
+ logger.info(`Unknown command ${command}`);
+
+ return 0;
+ }
+ }
+
+ /**
+ * Send reply report to device.
+ *
+ * @param {number} inputReportId -.
+ * @param {(string | boolean | undefined)} curOffHook -.
+ * @param {(string | undefined)} curMuted -.
+ * @returns {void} -.
+ */
+ private async sendReplyReport(
+ inputReportId: number,
+ curOffHook: string | boolean | undefined,
+ curMuted: boolean | string | undefined
+ ) {
+ const reportId = this.retriveInputReportId(inputReportId);
+
+
+ if (!this.deviceInfo || !this.deviceInfo.device || !this.deviceInfo.device.opened) {
+ logger.warn('[sendReplyReport] device is not opened or does not exist');
+
+ return;
+ }
+
+ if (reportId === 0 || curOffHook === undefined || curMuted === undefined) {
+ logger.warn(`[sendReplyReport] return, provided data not valid,
+ reportId: ${reportId}, curOffHook: ${curOffHook}, curMuted: ${curMuted}`);
+
+ return;
+ }
+
+ let reportData = new Uint8Array();
+ let muteReport;
+ let offHookReport;
+ let ringReport;
+
+ if (this.deviceCommand.outputReport.offHook.reportId === this.deviceCommand.outputReport.mute.reportId) {
+ muteReport = this.outputEventGenerators[DEVICE_USAGE.mute.usageId](curMuted);
+ offHookReport = this.outputEventGenerators[DEVICE_USAGE.offHook.usageId](curOffHook);
+ reportData = new Uint8Array(offHookReport);
+ for (const [ i, data ] of muteReport.entries()) {
+ // eslint-disable-next-line no-bitwise
+ reportData[i] |= data;
+ }
+ } else if (reportId === this.deviceCommand.outputReport.offHook.reportId) {
+ offHookReport = this.outputEventGenerators[DEVICE_USAGE.offHook.usageId](curOffHook);
+ reportData = new Uint8Array(offHookReport);
+ } else if (reportId === this.deviceCommand.outputReport.mute.reportId) {
+ muteReport = this.outputEventGenerators[DEVICE_USAGE.mute.usageId](curMuted);
+ reportData = new Uint8Array(muteReport);
+ } else if (reportId === this.deviceCommand.outputReport.ring.reportId) {
+ ringReport = this.outputEventGenerators[DEVICE_USAGE.mute.usageId](curMuted);
+ reportData = new Uint8Array(ringReport);
+ }
+
+ logger.warn(`[sendReplyReport] send device reply: reportId=${reportId}, reportData=${reportData}`);
+ await this.deviceInfo.device.sendReport(reportId, reportData);
+ }
+
+ /**
+ * Retrieve input report id.
+ *
+ * @private
+ * @param {number} inputReportId -.
+ * @returns {number} ReportId -.
+ */
+ private retriveInputReportId(inputReportId: number) {
+ let reportId = 0;
+
+ if (this.deviceCommand.outputReport.offHook.reportId === this.deviceCommand.outputReport.mute.reportId) {
+ reportId = this.deviceCommand.outputReport.offHook.reportId;
+ } else if (inputReportId === this.deviceCommand.inputReport.hookSwitch.reportId) {
+ reportId = this.deviceCommand.outputReport.offHook.reportId;
+ } else if (inputReportId === this.deviceCommand.inputReport.phoneMute.reportId) {
+ reportId = this.deviceCommand.outputReport.mute.reportId;
+ }
+
+ return reportId;
+ }
+
+ /**
+ * Get the hexadecimal bytes.
+ *
+ * @param {number|string} data -.
+ * @returns {string}
+ */
+ getHexByte(data: number | string) {
+ let hex = Number(data).toString(16);
+
+ while (hex.length < 2) {
+ hex = `0${hex}`;
+ }
+
+ return hex;
+ }
+}
diff --git a/tsconfig.native.json b/tsconfig.native.json
index dffb77111..74736a621 100644
--- a/tsconfig.native.json
+++ b/tsconfig.native.json
@@ -31,6 +31,7 @@
"react/features/stream-effects/noise-suppression",
"react/features/stream-effects/rnnoise",
"react/features/virtual-background",
+ "react/features/web-hid",
"react/features/whiteboard",
"**/web/*",
"**/*.web.ts",