Skip to content

Commit

Permalink
Merge pull request #108 from SuperFlyTV/feat/webhid-watcher
Browse files Browse the repository at this point in the history
Add implementation of XKeysWatcher for WebHID version
  • Loading branch information
nytamin authored Aug 26, 2024
2 parents f0ce2fb + 26ef1bc commit c5ead92
Show file tree
Hide file tree
Showing 14 changed files with 633 additions and 364 deletions.
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

0 comments on commit c5ead92

Please sign in to comment.