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 && ( +
+ ); +} + +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",