Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add implementation of XKeysWatcher for WebHID version #108

Merged
merged 9 commits into from
Aug 26, 2024
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ $ yarn add xkeys

### To use in browser

Uses WebHID, see list of supported browsers: [caniuse.com/webhid](https://caniuse.com/webhid).

```bash
$ npm install --save xkeys-webhid
or
Expand Down Expand Up @@ -63,7 +65,7 @@ This is the recommended way to use this library, to automatically be connected o
_Note: The watcher depends on the [node-usb](https://github.com/node-usb/node-usb) library, which might be unsupported on some platforms._

```javascript
const { XKeysWatcher } = require('xkeys')
const { XKeysWatcher } = require('xkeys') // or 'xkeys-webhid' in a browser

/*
This example connects to any connected x-keys panels and logs
Expand Down Expand Up @@ -181,13 +183,35 @@ listAllConnectedPanels().forEach((connectedPanel) => {

See the example implementation at [packages/webhid-demo](packages/webhid-demo).

```javascript
const { XKeysWatcher, requestXkeysPanels } = require('xkeys-webhid')

const watcher = new XKeysWatcher({})
watcher.on('error', (e) => {
console.log('Error in XKeysWatcher', e)
})
watcher.on('connected', (xkeysPanel) => {
// This will be triggered whenever a panel is connected, or permissions is granted.
// >> See the example above for setting up the xkeysPanel <<
})

myHTMLButton.addEventListener('click', async () => {
// Open the Request device permissions dialog:
requestXkeysPanels().catch((error) => console.error(error))

// Notes:
// When the user has granted permissions, the browser will remember this between sessions.
// However, if the panel is disconnected and reconnected, the user will have to grant permissions again.
})
```

### Demo

If you are using a Chromium v89+ based browser, you can try out the [webhid demo](https://SuperFlyTV.github.io/xkeys/).

## API documentation

### XKeysWatcher (Node.js only)
### XKeysWatcher

The XKeysWatcher has a few different options that can be set upon initialization:

Expand All @@ -204,6 +228,7 @@ watcher.on('error', (e) => {
watcher.on('connected', (xkeysPanel) => {
// xkeysPanel connected...
})
// Note: In a browser, user must first grant permissions to access the X-keys, using requestXkeysPanels().
```

#### automaticUnitIdMode
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './lib'
export * from './api'
export * from './products'
export * from './watcher'
export * from './genericHIDDevice'
export { XKeys } from './xkeys'
248 changes: 248 additions & 0 deletions packages/core/src/watcher.ts
Original file line number Diff line number Diff line change
@@ -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<HID_Identifier> {
on<U extends keyof XKeysWatcherEvents>(event: U, listener: XKeysWatcherEvents[U]): this
emit<U extends keyof XKeysWatcherEvents>(event: U, ...args: Parameters<XKeysWatcherEvents[U]>): 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<HID_Identifier> extends EventEmitter {
private updateConnectedDevicesTimeout: NodeJS.Timeout | null = null
private updateConnectedDevicesIsRunning = false
private updateConnectedDevicesRunAgain = false

private seenDevices = new Set<HID_Identifier>()
private setupXkeys = new Map<HID_Identifier, XKeys>()

/** 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<number, number>()

constructor(private _options?: XKeysWatcherOptions) {
super()

// Do a sweep for all currently connected X-keys panels:
this.triggerUpdateConnectedDevices(false)
}
protected get options(): Required<XKeysWatcherOptions> {
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<void> {
// 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<Set<HID_Identifier>>
protected abstract setupXkeysPanel(device: HID_Identifier): Promise<XKeys>

private async updateConnectedDevices(): Promise<void> {
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<void> {
// 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)
}
}
32 changes: 24 additions & 8 deletions packages/node/src/__mocks__/node-hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -15,16 +19,30 @@ 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<void> {
// void
}
async pause(): Promise<void> {
// void
throw new Error('Mock not implemented.')
}
async read(_timeOut?: number): Promise<Buffer | undefined> {
return undefined
Expand All @@ -37,13 +55,15 @@ export class HIDAsync extends EventEmitter {
}
async resume(): Promise<void> {
// void
throw new Error('Mock not implemented.')
}
async write(message: number[]): Promise<number> {
this.mockWriteHandler?.(this, message)
return 0
}
async setNonBlocking(_noBlock: boolean): Promise<void> {
// void
throw new Error('Mock not implemented.')
}

async generateDeviceInfo(): Promise<Device> {
Expand All @@ -52,17 +72,13 @@ export class HIDAsync extends EventEmitter {
}

async getDeviceInfo(): Promise<Device> {
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
}
Loading
Loading