From 34bbd3cbd765d97f3d4f52690f78d4cfef5817a2 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Fri, 16 Aug 2024 08:11:36 +0200 Subject: [PATCH] feat: Add XkeysWatcher to WebHID version, rework XkeysWatcher to share code between node & webHID versions --- packages/core/src/index.ts | 1 + packages/core/src/watcher.ts | 248 ++++++++++++++ packages/node/src/__mocks__/node-hid.ts | 32 +- packages/node/src/__tests__/watcher.spec.ts | 75 ++++ packages/node/src/methods.ts | 1 - packages/node/src/node-hid-wrapper.ts | 4 +- packages/node/src/watcher.ts | 321 ++++-------------- packages/webhid/src/globalConnectListener.ts | 79 +++++ .../webhid/src/globalDisconnectListener.ts | 34 -- packages/webhid/src/methods.ts | 9 +- packages/webhid/src/watcher.ts | 153 ++------- 11 files changed, 532 insertions(+), 425 deletions(-) create mode 100644 packages/core/src/watcher.ts create mode 100644 packages/node/src/__tests__/watcher.spec.ts create mode 100644 packages/webhid/src/globalConnectListener.ts delete mode 100644 packages/webhid/src/globalDisconnectListener.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 853deda..82a0d95 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,6 @@ export * from './lib' export * from './api' export * from './products' +export * from './watcher' export * from './genericHIDDevice' export { XKeys } from './xkeys' diff --git a/packages/core/src/watcher.ts b/packages/core/src/watcher.ts new file mode 100644 index 0000000..931fbfb --- /dev/null +++ b/packages/core/src/watcher.ts @@ -0,0 +1,248 @@ +import { EventEmitter } from 'events' +import { XKeys } from './xkeys' + +export interface XKeysWatcherOptions { + /** + * This activates the "Automatic UnitId mode", which enables several features: + * First, any x-keys panel with unitId===0 will be issued a (pseudo unique) unitId upon connection, in order for it to be uniquely identified. + * This allows for the connection-events to work a bit differently, mainly enabling the "reconnected"-event for when a panel has been disconnected, then reconnected again. + */ + automaticUnitIdMode?: boolean + + /** If set, will use polling for devices instead of watching for them directly. Might be a bit slower, but is more compatible. */ + usePolling?: boolean + /** If usePolling is set, the interval to use for checking for new devices. */ + pollingInterval?: number +} + +export interface XKeysWatcherEvents { + // Note: This interface defines strong typings for any events that are emitted by the XKeysWatcher class. + + connected: (xkeysPanel: XKeys) => void + error: (err: any) => void +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export declare interface GenericXKeysWatcher { + on(event: U, listener: XKeysWatcherEvents[U]): this + emit(event: U, ...args: Parameters): boolean +} +/** + * Set up a watcher for newly connected X-keys panels. + * Note: It is highly recommended to set up a listener for the disconnected event on the X-keys panel, to clean up after a disconnected device. + */ +export abstract class GenericXKeysWatcher extends EventEmitter { + private updateConnectedDevicesTimeout: NodeJS.Timeout | null = null + private updateConnectedDevicesIsRunning = false + private updateConnectedDevicesRunAgain = false + + private seenDevices = new Set() + private setupXkeys = new Map() + + /** A value that is incremented whenever we expect to find a new or removed device in updateConnectedDevices(). */ + private shouldFindChangedReTries = 0 + + protected isActive = true + + public debug = false + /** A list of the devices we've called setupNewDevice() for */ + // private setupXkeysPanels: XKeys[] = [] + private prevConnectedIdentifiers: { [key: string]: XKeys } = {} + /** Unique unitIds grouped into productId groups. */ + private uniqueIds = new Map() + + constructor(private _options?: XKeysWatcherOptions) { + super() + + // Do a sweep for all currently connected X-keys panels: + this.triggerUpdateConnectedDevices(false) + } + protected get options(): Required { + return { + automaticUnitIdMode: this._options?.automaticUnitIdMode ?? false, + usePolling: this._options?.usePolling ?? false, + pollingInterval: this._options?.pollingInterval ?? 1000, + } + } + /** + * Stop the watcher + * @param closeAllDevices Set to false in order to NOT close all devices. Use this if you only want to stop the watching. Defaults to true + */ + public async stop(closeAllDevices = true): Promise { + // To be implemented by the subclass and call super.stop() at the end + + this.isActive = false + + if (closeAllDevices) { + // In order for an application to close gracefully, + // we need to close all devices that we've called setupXkeysPanel() on: + + await Promise.all( + Array.from(this.seenDevices.keys()).map(async (device) => this.handleRemovedDevice(device)) + ) + } + } + + protected triggerUpdateConnectedDevices(somethingWasAddedOrRemoved: boolean): void { + if (somethingWasAddedOrRemoved) { + this.shouldFindChangedReTries++ + } + + if (this.updateConnectedDevicesIsRunning) { + // It is already running, so we'll run it again later, when it's done: + this.updateConnectedDevicesRunAgain = true + return + } else if (this.updateConnectedDevicesTimeout) { + // It is already scheduled to run. + + if (somethingWasAddedOrRemoved) { + // Set it to run now: + clearTimeout(this.updateConnectedDevicesTimeout) + this.updateConnectedDevicesTimeout = null + } else { + return + } + } + + if (!this.updateConnectedDevicesTimeout) { + this.updateConnectedDevicesRunAgain = false + this.updateConnectedDevicesTimeout = setTimeout( + () => { + this.updateConnectedDevicesTimeout = null + this.updateConnectedDevicesIsRunning = true + + this.updateConnectedDevices() + .catch(console.error) + .finally(() => { + this.updateConnectedDevicesIsRunning = false + if (this.updateConnectedDevicesRunAgain) this.triggerUpdateConnectedDevices(false) + }) + }, + somethingWasAddedOrRemoved ? 10 : Math.min(this.options.pollingInterval * 0.5, 300) + ) + } + } + protected abstract getConnectedDevices(): Promise> + protected abstract setupXkeysPanel(device: HID_Identifier): Promise + + private async updateConnectedDevices(): Promise { + this.debugLog('updateConnectedDevices') + + const connectedDevices = await this.getConnectedDevices() + + let removed = 0 + let added = 0 + // Removed devices: + for (const device of this.seenDevices.keys()) { + if (!connectedDevices.has(device)) { + // A device has been removed + this.debugLog('removed') + removed++ + + await this.handleRemovedDevice(device) + } + } + // Added devices: + for (const connectedDevice of connectedDevices.keys()) { + if (!this.seenDevices.has(connectedDevice)) { + // A device has been added + this.debugLog('added') + added++ + this.seenDevices.add(connectedDevice) + this.handleNewDevice(connectedDevice) + } + } + if (this.shouldFindChangedReTries > 0 && (added === 0 || removed === 0)) { + // We expected to find something changed, but didn't. + // Try again later: + this.shouldFindChangedReTries-- + this.triggerUpdateConnectedDevices(false) + } else { + this.shouldFindChangedReTries = 0 + } + } + + private handleNewDevice(device: HID_Identifier): void { + // This is called when a new device has been added / connected + + this.setupXkeysPanel(device) + .then(async (xKeysPanel: XKeys) => { + // Since this is async, check if the panel is still connected: + if (this.seenDevices.has(device)) { + await this.setupNewDevice(device, xKeysPanel) + } else { + await this.handleRemovedDevice(device) + } + }) + .catch((err) => { + this.emit('error', err) + }) + } + private async handleRemovedDevice(device: HID_Identifier) { + // This is called when a device has been removed / disconnected + this.seenDevices.delete(device) + + const xkeys = this.setupXkeys.get(device) + this.debugLog('aa') + if (xkeys) { + this.debugLog('bb') + await xkeys._handleDeviceDisconnected() + this.setupXkeys.delete(device) + } + } + + private async setupNewDevice(device: HID_Identifier, xKeysPanel: XKeys): Promise { + // Store for future reference: + this.setupXkeys.set(device, xKeysPanel) + + xKeysPanel.once('disconnected', () => { + this.handleRemovedDevice(device).catch((e) => this.emit('error', e)) + }) + + // this.setupXkeysPanels.push(xkeysPanel) + + if (this.options.automaticUnitIdMode) { + if (xKeysPanel.unitId === 0) { + // if it is 0, we assume that it's new from the factory and can be safely changed + xKeysPanel.setUnitId(this._getNextUniqueId(xKeysPanel)) // the lookup-cache is stored either in memory, or preferably on disk + } + // the PID+UID pair is enough to uniquely identify a panel. + const uniqueIdentifier: string = xKeysPanel.uniqueId + const previousXKeysPanel = this.prevConnectedIdentifiers[uniqueIdentifier] + if (previousXKeysPanel) { + // This panel has been connected before. + + // We want the XKeys-instance to emit a 'reconnected' event. + // This means that we kill off the newly created xkeysPanel, and + + await previousXKeysPanel._handleDeviceReconnected( + xKeysPanel._getHIDDevice(), + xKeysPanel._getDeviceInfo() + ) + } else { + // It seems that this panel hasn't been connected before + this.emit('connected', xKeysPanel) + this.prevConnectedIdentifiers[uniqueIdentifier] = xKeysPanel + } + } else { + // Default behavior: + this.emit('connected', xKeysPanel) + } + } + private _getNextUniqueId(xkeysPanel: XKeys): number { + let nextId = this.uniqueIds.get(xkeysPanel.info.productId) + if (!nextId) { + nextId = 32 // Starting at 32 + } else { + nextId++ + } + if (nextId > 255) throw new Error('No more unique ids available!') + + this.uniqueIds.set(xkeysPanel.info.productId, nextId) + + return nextId + } + + protected debugLog(...args: any[]): void { + if (this.debug) console.log(...args) + } +} diff --git a/packages/node/src/__mocks__/node-hid.ts b/packages/node/src/__mocks__/node-hid.ts index 56fd47c..1558093 100644 --- a/packages/node/src/__mocks__/node-hid.ts +++ b/packages/node/src/__mocks__/node-hid.ts @@ -6,6 +6,10 @@ let mockWriteHandler: undefined | ((hid: HIDAsync, message: number[]) => void) = export function setMockWriteHandler(handler: (hid: HIDAsync, message: number[]) => void) { mockWriteHandler = handler } +let mockDevices: Device[] = [] +export function mockSetDevices(devices: Device[]) { + mockDevices = devices +} // export class HID extends EventEmitter { export class HIDAsync extends EventEmitter { @@ -15,9 +19,22 @@ export class HIDAsync extends EventEmitter { return new HIDAsync(path) } - constructor(_path: string) { + private _deviceInfo: Device = { + vendorId: XKEYS_VENDOR_ID, + productId: 0, + release: 0, + interface: 0, + product: 'N/A Mock', + } + + constructor(path: string) { super() this.mockWriteHandler = mockWriteHandler + + const existingDevice = mockDevices.find((d) => d.path === path) + if (existingDevice) { + this._deviceInfo = existingDevice + } } // constructor(vid: number, pid: number); async close(): Promise { @@ -25,6 +42,7 @@ export class HIDAsync extends EventEmitter { } async pause(): Promise { // void + throw new Error('Mock not implemented.') } async read(_timeOut?: number): Promise { return undefined @@ -37,6 +55,7 @@ export class HIDAsync extends EventEmitter { } async resume(): Promise { // void + throw new Error('Mock not implemented.') } async write(message: number[]): Promise { this.mockWriteHandler?.(this, message) @@ -44,6 +63,7 @@ export class HIDAsync extends EventEmitter { } async setNonBlocking(_noBlock: boolean): Promise { // void + throw new Error('Mock not implemented.') } async generateDeviceInfo(): Promise { @@ -52,17 +72,13 @@ export class HIDAsync extends EventEmitter { } async getDeviceInfo(): Promise { - return { - vendorId: XKEYS_VENDOR_ID, - productId: 0, - release: 0, - interface: 0, - } + return this._deviceInfo } } export function devices(): Device[] { - return [] + return mockDevices } export function setDriverType(_type: 'hidraw' | 'libusb'): void { + throw new Error('Mock not implemented.') // void } diff --git a/packages/node/src/__tests__/watcher.spec.ts b/packages/node/src/__tests__/watcher.spec.ts new file mode 100644 index 0000000..1152bf5 --- /dev/null +++ b/packages/node/src/__tests__/watcher.spec.ts @@ -0,0 +1,75 @@ +import * as HID from 'node-hid' +import * as HIDMock from '../__mocks__/node-hid' +import { NodeHIDDevice, XKeys, XKeysWatcher } from '..' +import { handleXkeysMessages } from './lib' + +describe('XKeysWatcher', () => { + test('Detect device (w polling)', async () => { + const POLL_INTERVAL = 10 + NodeHIDDevice.CLOSE_WAIT_TIME = 0 // We can override this to speed up the unit tests + + HIDMock.setMockWriteHandler(handleXkeysMessages) + + const onError = jest.fn((e) => { + console.log('Error in XKeysWatcher', e) + }) + const onConnected = jest.fn((xkeys: XKeys) => { + xkeys.on('disconnected', () => { + onDisconnected() + xkeys.removeAllListeners() + }) + }) + const onDisconnected = jest.fn(() => {}) + + const watcher = new XKeysWatcher({ + usePolling: true, + pollingInterval: POLL_INTERVAL, + }) + watcher.on('error', onError) + watcher.on('connected', onConnected) + + try { + await sleep(POLL_INTERVAL * 2) + expect(onConnected).toHaveBeenCalledTimes(0) + + // Add a device: + { + const hidDevice = { + vendorId: XKeys.vendorId, + productId: 1029, + interface: 0, + path: 'abc123', + product: 'XK-24 MOCK', + } as HID.Device + + HIDMock.mockSetDevices([hidDevice]) + + // Listen for the 'connected' event: + await sleep(POLL_INTERVAL) + expect(onConnected).toHaveBeenCalledTimes(1) + } + + // Remove the device: + { + HIDMock.mockSetDevices([]) + + await sleep(POLL_INTERVAL) + expect(onDisconnected).toHaveBeenCalledTimes(1) + } + } catch (e) { + throw e + } finally { + // Cleanup: + await watcher.stop() + } + // Ensure the event handlers haven't been called again: + await sleep(POLL_INTERVAL) + expect(onDisconnected).toHaveBeenCalledTimes(1) + expect(onConnected).toHaveBeenCalledTimes(1) + + expect(onError).toHaveBeenCalledTimes(0) + }) +}) +async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/packages/node/src/methods.ts b/packages/node/src/methods.ts index eac8c61..9f22cb1 100644 --- a/packages/node/src/methods.ts +++ b/packages/node/src/methods.ts @@ -28,7 +28,6 @@ export async function setupXkeysPanel( interface: number } | undefined - try { if (!devicePathOrHIDDevice) { // Device not provided, will then select any connected device: diff --git a/packages/node/src/node-hid-wrapper.ts b/packages/node/src/node-hid-wrapper.ts index 9e21ff7..3bd24c6 100644 --- a/packages/node/src/node-hid-wrapper.ts +++ b/packages/node/src/node-hid-wrapper.ts @@ -8,6 +8,8 @@ import * as HID from 'node-hid' * This translates it into the common format (@see HIDDevice) defined by @xkeys-lib/core */ export class NodeHIDDevice extends EventEmitter implements HIDDevice { + static CLOSE_WAIT_TIME = 300 + constructor(private device: HID.HIDAsync) { super() @@ -27,7 +29,7 @@ export class NodeHIDDevice extends EventEmitter implements HIDDevice { // For some unknown reason, we need to wait a bit before returning because it // appears that the HID-device isn't actually closed properly until after a short while. // (This issue has been observed in Electron, where a app.quit() causes the application to crash with "Exit status 3221226505".) - await new Promise((resolve) => setTimeout(resolve, 300)) + await new Promise((resolve) => setTimeout(resolve, NodeHIDDevice.CLOSE_WAIT_TIME)) this.device.removeListener('error', this._handleError) this.device.removeListener('data', this._handleData) diff --git a/packages/node/src/watcher.ts b/packages/node/src/watcher.ts index ea03ba5..ed6376c 100644 --- a/packages/node/src/watcher.ts +++ b/packages/node/src/watcher.ts @@ -1,97 +1,36 @@ import type { usb } from 'usb' -import { EventEmitter } from 'events' -import { XKeys, XKEYS_VENDOR_ID } from '@xkeys-lib/core' +import { XKeys, XKEYS_VENDOR_ID, GenericXKeysWatcher, XKeysWatcherOptions } from '@xkeys-lib/core' import { listAllConnectedPanels, setupXkeysPanel } from '.' -let USBImport: typeof usb | undefined -let hasTriedImport = false - -// Because usb is an optional dependency, we have to use in a somewhat messy way: -function USBDetect(): typeof usb { - if (USBImport) return USBImport - - if (!hasTriedImport) { - hasTriedImport = true - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const usb: typeof import('usb') = require('usb') - USBImport = usb.usb - return USBImport - } catch (err) { - // It's not installed - } - } - // else emit error: - throw `XKeysWatcher requires the dependency "usb" to be installed, it might have been skipped due to your platform being unsupported (this is an issue with "usb", not the X-keys library). -Possible solutions are: -* You can try to install the dependency manually, by running "npm install usb". -* Use the fallback "usePolling" functionality instead: new XKeysWatcher({ usePolling: true}) -* Otherwise you can still connect to X-keys panels manually by using XKeys.setupXkeysPanel(). -` -} - -export interface XKeysWatcherEvents { - // Note: This interface defines strong typings for any events that are emitted by the XKeysWatcher class. - - connected: (xkeysPanel: XKeys) => void - error: (err: any) => void -} - -export declare interface XKeysWatcher { - on(event: U, listener: XKeysWatcherEvents[U]): this - emit(event: U, ...args: Parameters): boolean -} /** * Set up a watcher for newly connected X-keys panels. * Note: It is highly recommended to set up a listener for the disconnected event on the X-keys panel, to clean up after a disconnected device. */ -export class XKeysWatcher extends EventEmitter { - private seenDevicePaths: { - [devicePath: string]: { - xkeys?: XKeys - } - } = {} - private isMonitoring = true - private updateConnectedDevicesTimeout: NodeJS.Timeout | null = null - private updateConnectedDevicesIsRunning = false - private updateConnectedDevicesRunAgain = false - private shouldFindChangedReTries = 0 - - public debug = false - /** A list of the devices we've called setupXkeysPanels for */ - private setupXkeysPanels: XKeys[] = [] - private prevConnectedIdentifiers: { [key: string]: XKeys } = {} - /** Unique unitIds grouped into productId groups. */ - private uniqueIds = new Map() +export class XKeysWatcher extends GenericXKeysWatcher { private pollingInterval: NodeJS.Timeout | undefined = undefined - constructor(private options?: XKeysWatcherOptions) { - super() + constructor(options?: XKeysWatcherOptions) { + super(options) - if (!this.options?.usePolling) { + if (!this.options.usePolling) { // Watch for added devices: - USBDetect().on('attach', this.onAddedUSBDevice) - USBDetect().on('detach', this.onRemovedUSBDevice) + USBImport.USBDetect().on('attach', this.onAddedUSBDevice) + USBImport.USBDetect().on('detach', this.onRemovedUSBDevice) } else { this.pollingInterval = setInterval(() => { - this.triggerUpdateConnectedDevices(true) - }, this.options?.pollingInterval ?? 1000) + this.triggerUpdateConnectedDevices(false) + }, this.options.pollingInterval) } - - // Also do a sweep for all currently connected X-keys panels: - this.triggerUpdateConnectedDevices(true) } /** * Stop the watcher * @param closeAllDevices Set to false in order to NOT close all devices. Use this if you only want to stop the watching. Defaults to true */ public async stop(closeAllDevices = true): Promise { - this.isMonitoring = false - - if (!this.options?.usePolling) { + if (!this.options.usePolling) { // Remove the listeners: - USBDetect().off('attach', this.onAddedUSBDevice) - USBDetect().off('detach', this.onRemovedUSBDevice) + USBImport.USBDetect().off('attach', this.onAddedUSBDevice) + USBImport.USBDetect().off('detach', this.onRemovedUSBDevice) } if (this.pollingInterval) { @@ -99,205 +38,71 @@ export class XKeysWatcher extends EventEmitter { this.pollingInterval = undefined } - if (closeAllDevices) { - // In order for an application to close gracefully, - // we need to close all devices that we've called setupXkeysPanel() on: - const ps: Promise[] = [] - for (const xKeysPanel of this.setupXkeysPanels) { - ps.push(xKeysPanel.close()) - } - await Promise.all(ps) - } - } - private onAddedUSBDevice = (device: usb.Device) => { - if (device.deviceDescriptor.idVendor === XKEYS_VENDOR_ID) { - // Called whenever a new USB device is added - this.debugLog('onAddedUSBDevice') - if (this.isMonitoring) { - this.shouldFindChangedReTries++ - this.triggerUpdateConnectedDevices(true) - } - } - } - private onRemovedUSBDevice = (device: usb.Device) => { - if (device.deviceDescriptor.idVendor === XKEYS_VENDOR_ID) { - // Called whenever a new USB device is removed - this.debugLog('onRemovedUSBDevice') - if (this.isMonitoring) { - this.shouldFindChangedReTries++ - this.triggerUpdateConnectedDevices(true) - } - } + await super.stop(closeAllDevices) } - private triggerUpdateConnectedDevices(asap: boolean): void { - if (this.updateConnectedDevicesIsRunning) { - // It is already running, so we'll run it again later, when it's done: - this.updateConnectedDevicesRunAgain = true - return - } else if (this.updateConnectedDevicesTimeout) { - // It is already scheduled to run. - - if (asap) { - // Set it to run now: - clearTimeout(this.updateConnectedDevicesTimeout) - this.updateConnectedDevicesTimeout = null - } else { - return - } - } - - if (!this.updateConnectedDevicesTimeout) { - this.updateConnectedDevicesRunAgain = false - this.updateConnectedDevicesTimeout = setTimeout( - () => { - this.updateConnectedDevicesTimeout = null - this.updateConnectedDevicesIsRunning = true - this.updateConnectedDevices() - .catch(console.error) - .finally(() => { - this.updateConnectedDevicesIsRunning = false - if (this.updateConnectedDevicesRunAgain) this.triggerUpdateConnectedDevices(true) - }) - }, - asap ? 10 : 1000 - ) - } - } - private async updateConnectedDevices(): Promise { - const pathMap: { [devicePath: string]: true } = {} + protected async getConnectedDevices(): Promise> { + // Returns a Set of devicePaths of the connected devices + const connectedDevices = new Set() - this.debugLog('updateConnectedDevices') - // Note: - // This implementation is a bit awkward, - // there isn't a good way to relate the output from usb to node-hid devices - // So we're just using the events to trigger a re-check for new devices and cache the seen devices - - listAllConnectedPanels().forEach((xkeysDevice) => { + for (const xkeysDevice of listAllConnectedPanels()) { if (xkeysDevice.path) { - pathMap[xkeysDevice.path] = true + connectedDevices.add(xkeysDevice.path) } else { this.emit('error', `XKeysWatcher: Device missing path.`) } - }) - - let removed = 0 - let added = 0 - // Removed devices: - for (const [devicePath, o] of Object.entries<{ xkeys?: XKeys }>(this.seenDevicePaths)) { - if (!pathMap[devicePath]) { - // A device has been removed - this.debugLog('removed') - removed++ - if (o.xkeys) await this.handleRemovedDevice(o.xkeys) - - delete this.seenDevicePaths[devicePath] - } - } - // Added devices: - for (const devicePath of Object.keys(pathMap)) { - if (!this.seenDevicePaths[devicePath]) { - // A device has been added - this.debugLog('added') - added++ - this.seenDevicePaths[devicePath] = {} - this.handleNewDevice(devicePath) - } - } - if (this.shouldFindChangedReTries > 0 && (added === 0 || removed === 0)) { - // We expected to find something changed, but didn't. - // Try again later: - this.shouldFindChangedReTries-- - this.triggerUpdateConnectedDevices(false) - } else { - this.shouldFindChangedReTries = 0 } + return connectedDevices } - private handleNewDevice(devicePath: string): void { - this.debugLog('handleNewDevice', devicePath) - - setupXkeysPanel(devicePath) - .then(async (xkeysPanel: XKeys) => { - this.setupXkeysPanels.push(xkeysPanel) - // Since this is async, check if the panel is still connected - if (this.seenDevicePaths[devicePath]) { - // yes, it is still connected - - // Listen to the disconnected event, because often if comes faster from the X-keys than from this watcher. - const onDisconnected = () => { - delete this.seenDevicePaths[devicePath] - xkeysPanel.removeListener('disconnected', onDisconnected) - } - xkeysPanel.on('disconnected', onDisconnected) - - // Store for future reference: - this.seenDevicePaths[devicePath].xkeys = xkeysPanel - - if (this.options?.automaticUnitIdMode) { - if (xkeysPanel.unitId === 0) { - // if it is 0, we assume that it's new from the factory and can be safely changed - xkeysPanel.setUnitId(this._getNextUniqueId(xkeysPanel)) // the lookup-cache is stored either in memory, or preferably on disk - } - // the PID+UID pair is enough to uniquely identify a panel. - const uniqueIdentifier: string = xkeysPanel.uniqueId - const previousXKeysPanel = this.prevConnectedIdentifiers[uniqueIdentifier] - if (previousXKeysPanel) { - // This panel has been connected before. - - // We want the XKeys-instance to emit a 'reconnected' event. - // This means that we kill off the newly created xkeysPanel, and + protected async setupXkeysPanel(devicePath: string): Promise { + return setupXkeysPanel(devicePath) + } + private onAddedUSBDevice = (device: usb.Device) => { + // Called whenever a new USB device is added + // Note: + // There isn't a good way to relate the output from usb to node-hid devices + // So we're just using the events to trigger a re-check for new devices and cache the seen devices + if (!this.isActive) return + if (device.deviceDescriptor.idVendor !== XKEYS_VENDOR_ID) return - await previousXKeysPanel._handleDeviceReconnected( - xkeysPanel._getHIDDevice(), - xkeysPanel._getDeviceInfo() - ) - } else { - // It seems that this panel hasn't been connected before - this.emit('connected', xkeysPanel) - this.prevConnectedIdentifiers[uniqueIdentifier] = xkeysPanel - } - } else { - // Default behaviour: - this.emit('connected', xkeysPanel) - } - } else { - await this.handleRemovedDevice(xkeysPanel) - } - }) - .catch((err) => { - this.emit('error', err) - }) + this.debugLog('onAddedUSBDevice') + this.triggerUpdateConnectedDevices(true) } - private _getNextUniqueId(xkeysPanel: XKeys): number { - let nextId = this.uniqueIds.get(xkeysPanel.info.productId) - if (!nextId) { - nextId = 32 // Starting at 32 - } else { - nextId++ - } - if (nextId > 255) throw new Error('No more unique ids available!') + private onRemovedUSBDevice = (device: usb.Device) => { + // Called whenever a new USB device is removed - this.uniqueIds.set(xkeysPanel.info.productId, nextId) + if (!this.isActive) return + if (device.deviceDescriptor.idVendor !== XKEYS_VENDOR_ID) return + this.debugLog('onRemovedUSBDevice') - return nextId - } - private async handleRemovedDevice(xkeysPanel: XKeys) { - await xkeysPanel._handleDeviceDisconnected() - } - private debugLog(...args: any[]) { - if (this.debug) console.log(...args) + this.triggerUpdateConnectedDevices(true) } } -export interface XKeysWatcherOptions { - /** - * This activates the "Automatic UnitId mode", which enables several features: - * First, any x-keys panel with unitId===0 will be issued a (pseudo unique) unitId upon connection, in order for it to be uniquely identified. - * This allows for the connection-events to work a bit differently, mainly enabling the "reconnected"-event for when a panel has been disconnected, then reconnected again. - */ - automaticUnitIdMode?: boolean - /** If set, will use polling for devices instead of watching for them directly. Might be a bit slower, but is more compatible. */ - usePolling?: boolean - /** If usePolling is set, the interval to use for checking for new devices. */ - pollingInterval?: number +class USBImport { + private static USBImport: typeof usb | undefined + private static hasTriedImport = false + // Because usb is an optional dependency, we have to use in a somewhat messy way: + static USBDetect(): typeof usb { + if (this.USBImport) return this.USBImport + + if (!this.hasTriedImport) { + this.hasTriedImport = true + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const usb: typeof import('usb') = require('usb') + this.USBImport = usb.usb + return this.USBImport + } catch (err) { + // It's not installed + } + } + // else emit error: + throw `XKeysWatcher requires the dependency "usb" to be installed, it might have been skipped due to your platform being unsupported (this is an issue with "usb", not the X-keys library). + Possible solutions are: + * You can try to install the dependency manually, by running "npm install usb". + * Use the fallback "usePolling" functionality instead: new XKeysWatcher({ usePolling: true}) + * Otherwise you can still connect to X-keys panels manually by using XKeys.setupXkeysPanel(). + ` + } } diff --git a/packages/webhid/src/globalConnectListener.ts b/packages/webhid/src/globalConnectListener.ts new file mode 100644 index 0000000..758fa8c --- /dev/null +++ b/packages/webhid/src/globalConnectListener.ts @@ -0,0 +1,79 @@ +/** + * This class is used to register listener for connect and disconnect events for HID devices. + * It allows for a few clever tricks, such as + * * listenForDisconnectOnce() listens for a disconnect event for a specific device, and then removes the listener. + * * handles a special case where the 'connect' event isn't fired when adding permissions for a HID device. + */ +export class GlobalConnectListener { + private static anyConnectListeners = new Set<() => void>() + private static anyDisconnectListeners = new Set<() => void>() + private static disconnectListenersOnce = new Map void>() + + private static isSetup = false + + /** Add listener for any connect event */ + static listenForAnyConnect(callback: () => void): { stop: () => void } { + this.setup() + this.anyConnectListeners.add(callback) + return { + stop: () => this.anyConnectListeners.delete(callback), + } + } + /** Add listener for any disconnect event */ + static listenForAnyDisconnect(callback: () => void): { stop: () => void } { + this.setup() + this.anyDisconnectListeners.add(callback) + return { + stop: () => this.anyDisconnectListeners.delete(callback), + } + } + + /** Add listener for disconnect event, for a HIDDevice. The callback will be fired once. */ + static listenForDisconnectOnce(device: HIDDevice, callback: () => void): void { + this.setup() + this.disconnectListenersOnce.set(device, callback) + } + + static notifyConnectedDevice(): void { + this.handleConnect() + } + + private static setup() { + if (this.isSetup) return + navigator.hid.addEventListener('disconnect', this.handleDisconnect) + navigator.hid.addEventListener('connect', this.handleConnect) + this.isSetup = true + } + private static handleDisconnect = (ev: HIDConnectionEvent) => { + this.anyDisconnectListeners.forEach((callback) => callback()) + + this.disconnectListenersOnce.forEach((callback, device) => { + if (device === ev.device) { + callback() + // Also remove the listener: + this.disconnectListenersOnce.delete(device) + } + }) + + this.maybeTeardown() + } + private static handleConnect = () => { + this.anyConnectListeners.forEach((callback) => callback()) + } + private static maybeTeardown() { + if ( + this.disconnectListenersOnce.size === 0 && + this.anyDisconnectListeners.size === 0 && + this.anyConnectListeners.size === 0 + ) { + // If there are no listeners, we can teardown the global listener: + this.teardown() + } + } + private static teardown() { + navigator.hid.removeEventListener('disconnect', this.handleDisconnect) + navigator.hid.removeEventListener('connect', this.handleConnect) + this.disconnectListenersOnce.clear() + this.isSetup = false + } +} diff --git a/packages/webhid/src/globalDisconnectListener.ts b/packages/webhid/src/globalDisconnectListener.ts deleted file mode 100644 index 1fb53c1..0000000 --- a/packages/webhid/src/globalDisconnectListener.ts +++ /dev/null @@ -1,34 +0,0 @@ -export class GlobalDisconnectListener { - private static listeners = new Map void>() - private static isSetup = false - - /** Add listener for disconnect event, for a HIDDevice. The callback will be fired once. */ - static listenForDisconnect(device: HIDDevice, callback: () => void): void { - this.setup() - this.listeners.set(device, callback) - } - - private static setup() { - if (this.isSetup) return - navigator.hid.addEventListener('disconnect', this.handleDisconnect) - this.isSetup = true - } - private static handleDisconnect = (ev: HIDConnectionEvent) => { - this.listeners.forEach((callback, device) => { - if (device === ev.device) { - callback() - // Also remove the listener: - this.listeners.delete(device) - } - }) - if (this.listeners.size === 0) { - // If there are not listeners, we can teardown the global listener: - this.teardown() - } - } - private static teardown() { - navigator.hid.removeEventListener('disconnect', this.handleDisconnect) - this.listeners.clear() - this.isSetup = false - } -} diff --git a/packages/webhid/src/methods.ts b/packages/webhid/src/methods.ts index 5afd0cc..7a8be47 100644 --- a/packages/webhid/src/methods.ts +++ b/packages/webhid/src/methods.ts @@ -1,6 +1,6 @@ import { XKeys, XKEYS_VENDOR_ID } from '@xkeys-lib/core' import { WebHIDDevice } from './web-hid-wrapper' -import { GlobalDisconnectListener } from './globalDisconnectListener' +import { GlobalConnectListener } from './globalConnectListener' /** Prompts the user for which X-keys panel to select */ export async function requestXkeysPanels(): Promise { @@ -11,7 +11,10 @@ export async function requestXkeysPanels(): Promise { }, ], }) - return allDevices.filter(isValidXkeysUsage) + const newDevices = allDevices.filter(isValidXkeysUsage) + + if (newDevices.length > 0) GlobalConnectListener.notifyConnectedDevice() // A fix for when the 'connect' event isn't fired + return newDevices } /** * Reopen previously selected devices. @@ -60,7 +63,7 @@ export async function setupXkeysPanel(browserDevice: HIDDevice): Promise ) // Setup listener for disconnect: - GlobalDisconnectListener.listenForDisconnect(browserDevice, () => { + GlobalConnectListener.listenForDisconnectOnce(browserDevice, () => { xkeys._handleDeviceDisconnected().catch((e) => { console.error(`Xkeys: Error handling disconnect:`, e) }) diff --git a/packages/webhid/src/watcher.ts b/packages/webhid/src/watcher.ts index 0839394..d812dde 100644 --- a/packages/webhid/src/watcher.ts +++ b/packages/webhid/src/watcher.ts @@ -1,141 +1,54 @@ -import { EventEmitter } from 'events' -import { XKeys } from '@xkeys-lib/core' +import { GenericXKeysWatcher, XKeys, XKeysWatcherOptions } from '@xkeys-lib/core' import { getOpenedXKeysPanels, setupXkeysPanel } from './methods' - -export type XKeysWatcherEvents = { - connected: (xkeysPanel: XKeys) => void - error: (err: unknown) => void -} - -export declare interface XKeysWatcher { - // Note: This interface defines strong typings for any events that are emitted by the XKeysWatcher class. - on(event: U, listener: XKeysWatcherEvents[U]): this - emit(event: U, ...args: Parameters): boolean -} - -const DEFAULT_POLLING_INTERVAL_MS = 1000 - +import { GlobalConnectListener } from './globalConnectListener' /** * Set up a watcher for newly connected X-keys panels. * Note: It is highly recommended to set up a listener for the disconnected event on the X-keys panel, to clean up after a disconnected device. */ -export class XKeysWatcher extends EventEmitter { - private pollingTimeout: number | undefined = undefined - - public debug = false - - private readonly seenHidDevices: Set = new Set() - /** A list of the devices we've called setupXkeysPanels for. */ - private setupXKeysPanels: XKeys[] = [] - private readonly hidDeviceToXKeysPanel: WeakMap = new WeakMap() - private readonly hidDeviceToDisconnectedListener: WeakMap void> = new WeakMap() - - constructor(private readonly options?: XKeysWatcherOptions) { - super() - this.triggerUpdateConnectedDevices(true) - navigator.hid.addEventListener('disconnect', this.handleDisconnect) +export class XKeysWatcher extends GenericXKeysWatcher { + private eventListeners: { stop: () => void }[] = [] + private pollingInterval: NodeJS.Timeout | undefined = undefined + + constructor(options?: XKeysWatcherOptions) { + super(options) + + if (!this.options.usePolling) { + this.eventListeners.push(GlobalConnectListener.listenForAnyDisconnect(this.handleConnectEvent)) + this.eventListeners.push(GlobalConnectListener.listenForAnyConnect(this.handleConnectEvent)) + } else { + this.pollingInterval = setInterval(() => { + this.triggerUpdateConnectedDevices(false) + }, this.options.pollingInterval) + } } /** - * Stop the watcher. - * - * @param closeAllDevices Set to false in order to NOT close all devices. Use this if you only want to stop the watching. Defaults to true. + * Stop the watcher + * @param closeAllDevices Set to false in order to NOT close all devices. Use this if you only want to stop the watching. Defaults to true */ public async stop(closeAllDevices = true): Promise { - navigator.hid.removeEventListener('disconnect', this.handleDisconnect) + this.eventListeners.forEach((listener) => listener.stop()) - if (this.pollingTimeout !== undefined) { - clearTimeout(this.pollingTimeout) - this.pollingTimeout = undefined + if (this.pollingInterval) { + clearInterval(this.pollingInterval) + this.pollingInterval = undefined } - if (closeAllDevices) { - // In order for an application to close gracefully, - // we need to close all devices that we've called setupXkeysPanel() on - await Promise.all(this.setupXKeysPanels.map((xKeys) => xKeys.close())) - } - } - - private triggerUpdateConnectedDevices(immediate: boolean) { - this.pollingTimeout = (setTimeout as Window['setTimeout'])( - async () => { - try { - await this.updateConnectedDevices() - } catch (e) { - console.error(e) - } - this.triggerUpdateConnectedDevices(false) - }, - immediate ? 0 : this.options?.pollingInterval ?? DEFAULT_POLLING_INTERVAL_MS - ) - } - - private async updateConnectedDevices() { - const devices = await getOpenedXKeysPanels() - - // Removed devices: - this.seenHidDevices.forEach((device) => { - if (!devices.includes(device)) { - this.debugLog('removed') - this.seenHidDevices.delete(device) - } - }) - const unseenDevices = devices.filter((device) => !this.seenHidDevices.has(device)) - unseenDevices.forEach((device) => this.seenHidDevices.add(device)) - - // Added devices: - await Promise.all( - unseenDevices.map((device) => { - this.debugLog('added') - return this.handleNewDevice(device) - }) - ) + await super.stop(closeAllDevices) } - private async handleNewDevice(device: HIDDevice) { - this.debugLog('handleNewDevice', device.productId) - - try { - const xKeysPanel = await setupXkeysPanel(device) - this.hidDeviceToXKeysPanel.set(device, xKeysPanel) - this.setupXKeysPanels.push(xKeysPanel) - - this.emit('connected', xKeysPanel) - - const handleDisconnected = () => { - this.cleanupDevice(device) - } - this.hidDeviceToDisconnectedListener.set(device, handleDisconnected) - xKeysPanel.once('disconnected', handleDisconnected) - } catch (e) { - this.emit('error', e) - } + protected async getConnectedDevices(): Promise> { + // Returns a Set of devicePaths of the connected devices + return new Set(await getOpenedXKeysPanels()) } - - private handleDisconnect = (event: HIDConnectionEvent) => { - this.cleanupDevice(event.device) + protected async setupXkeysPanel(device: HIDDevice): Promise { + return setupXkeysPanel(device) } + private handleConnectEvent = () => { + // Called whenever a device is connected or disconnected - private cleanupDevice(device: HIDDevice) { - const xKeys = this.hidDeviceToXKeysPanel.get(device) - const disconnectedListener = this.hidDeviceToDisconnectedListener.get(device) - if (xKeys && disconnectedListener) { - xKeys.removeListener('disconnected', disconnectedListener) - } - this.seenHidDevices.delete(device) - this.hidDeviceToXKeysPanel.delete(device) - this.hidDeviceToDisconnectedListener.delete(device) - } + if (!this.isActive) return - private debugLog(...args: any[]) { - if (this.debug) console.log(...args) + this.triggerUpdateConnectedDevices(true) } } - -export type XKeysWatcherOptions = { - /** - * The interval to use for checking for new devices (defaults to 1000) [ms]. - * Note: This is a lower bound; the real poll rate may be slower if individual polling cycles take longer than the interval. - */ - pollingInterval?: number -}