diff --git a/packages/core/src/genericHIDDevice.ts b/packages/core/src/genericHIDDevice.ts index 9067f14..b8407ee 100644 --- a/packages/core/src/genericHIDDevice.ts +++ b/packages/core/src/genericHIDDevice.ts @@ -8,5 +8,8 @@ export interface HIDDevice { write(data: number[]): void + /** Returns a promise which settles when all writes has completed */ + flush(): Promise + close(): Promise } diff --git a/packages/core/src/xkeys.ts b/packages/core/src/xkeys.ts index 2197650..d1f6cae 100644 --- a/packages/core/src/xkeys.ts +++ b/packages/core/src/xkeys.ts @@ -647,6 +647,12 @@ export class XKeys extends EventEmitter { public writeData(message: HIDMessage): void { this._write(message) } + /** + * Returns a Promise that settles when all writes have been completed + */ + public async flush(): Promise { + await this.device.flush() + } /** (Internal function) Called when there has been detected that the device has been disconnected */ public async _handleDeviceDisconnected(): Promise { diff --git a/packages/node/package.json b/packages/node/package.json index ed27df8..4983aac 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -56,6 +56,7 @@ "dependencies": { "@xkeys-lib/core": "3.2.0", "node-hid": "^3.0.0", + "p-queue": "^6.6.2", "tslib": "^2.4.0" }, "optionalDependencies": { diff --git a/packages/node/src/__mocks__/node-hid.ts b/packages/node/src/__mocks__/node-hid.ts index 1558093..1b72df7 100644 --- a/packages/node/src/__mocks__/node-hid.ts +++ b/packages/node/src/__mocks__/node-hid.ts @@ -6,6 +6,9 @@ let mockWriteHandler: undefined | ((hid: HIDAsync, message: number[]) => void) = export function setMockWriteHandler(handler: (hid: HIDAsync, message: number[]) => void) { mockWriteHandler = handler } +export function resetMockWriteHandler() { + mockWriteHandler = undefined +} let mockDevices: Device[] = [] export function mockSetDevices(devices: Device[]) { mockDevices = devices @@ -58,7 +61,7 @@ export class HIDAsync extends EventEmitter { throw new Error('Mock not implemented.') } async write(message: number[]): Promise { - this.mockWriteHandler?.(this, message) + await this.mockWriteHandler?.(this, message) return 0 } async setNonBlocking(_noBlock: boolean): Promise { diff --git a/packages/node/src/__tests__/recordings.spec.ts b/packages/node/src/__tests__/recordings.spec.ts index 98a6c00..03629a5 100644 --- a/packages/node/src/__tests__/recordings.spec.ts +++ b/packages/node/src/__tests__/recordings.spec.ts @@ -26,6 +26,9 @@ describe('Recorded tests', () => { expect(HID.setMockWriteHandler).toBeTruthy() }) beforeEach(() => {}) + afterEach(() => { + HIDMock.resetMockWriteHandler() + }) const dirPath = './src/__tests__/recordings/' @@ -133,6 +136,8 @@ describe('Recorded tests', () => { // @ts-expect-error hack xkeysDevice[action.method](...action.arguments) + await xkeysDevice.flush() + expect(getSentData()).toEqual(action.sentData) resetSentData() } catch (err) { diff --git a/packages/node/src/__tests__/watcher.spec.ts b/packages/node/src/__tests__/watcher.spec.ts index a8b2d3c..74448dc 100644 --- a/packages/node/src/__tests__/watcher.spec.ts +++ b/packages/node/src/__tests__/watcher.spec.ts @@ -4,6 +4,9 @@ import { NodeHIDDevice, XKeys, XKeysWatcher } from '..' import { handleXkeysMessages, sleep, sleepTicks } from './lib' describe('XKeysWatcher', () => { + afterEach(() => { + HIDMock.resetMockWriteHandler() + }) 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 diff --git a/packages/node/src/__tests__/xkeys.spec.ts b/packages/node/src/__tests__/xkeys.spec.ts index ab36b3b..8cab9ab 100644 --- a/packages/node/src/__tests__/xkeys.spec.ts +++ b/packages/node/src/__tests__/xkeys.spec.ts @@ -1,9 +1,12 @@ import * as HID from 'node-hid' import * as HIDMock from '../__mocks__/node-hid' import { setupXkeysPanel, XKeys } from '../' -import { getSentData, handleXkeysMessages, resetSentData } from './lib' +import { getSentData, handleXkeysMessages, resetSentData, sleep } from './lib' describe('Unit tests', () => { + afterEach(() => { + HIDMock.resetMockWriteHandler() + }) test('calculateDelta', () => { expect(XKeys.calculateDelta(100, 100)).toBe(0) expect(XKeys.calculateDelta(110, 100)).toBe(10) @@ -40,101 +43,223 @@ describe('Unit tests', () => { expect(myXkeysPanel.info).toMatchSnapshot() resetSentData() myXkeysPanel.getButtons() + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setIndicatorLED(5, true) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setIndicatorLED(5, false) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() + myXkeysPanel.setIndicatorLED(5, true, true) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setBacklight(5, '59f') + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setBacklight(5, '5599ff') + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setBacklight(5, '#5599ff') + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setBacklight(5, { r: 45, g: 210, b: 255 }) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setBacklight(5, true) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setBacklight(5, false) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setBacklight(5, null) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setBacklight(5, null) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setBacklight(5, '59f', true) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setAllBacklights('59f') + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setAllBacklights('5599ff') + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setAllBacklights('#5599ff') + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setAllBacklights({ r: 45, g: 210, b: 255 }) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setAllBacklights(true) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setAllBacklights(false) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setAllBacklights(null) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setAllBacklights(null) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.toggleAllBacklights() + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setBacklightIntensity(100) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setBacklightIntensity(0, 255) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.saveBackLights() + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setFrequency(127) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.setUnitId(42) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() myXkeysPanel.rebootDevice() + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() // expect(myXkeysPanel.writeLcdDisplay(line: number, displayChar: string, backlight: boolean) + await myXkeysPanel.flush() // expect(getSentData()).toMatchSnapshot() // resetSentData() myXkeysPanel.writeData([0, 1, 2, 3, 4]) + await myXkeysPanel.flush() expect(getSentData()).toMatchSnapshot() resetSentData() expect(onError).toHaveBeenCalledTimes(0) }) + test('flush()', async () => { + const hidDevice = { + vendorId: XKeys.vendorId, + productId: 1029, + interface: 0, + path: 'mockPath', + } as HID.Device + + const mockWriteStart = jest.fn() + const mockWriteEnd = jest.fn() + HIDMock.setMockWriteHandler(async (hid, message) => { + mockWriteStart() + await sleep(10) + mockWriteEnd() + handleXkeysMessages(hid, message) + }) + + const myXkeysPanel = await setupXkeysPanel(hidDevice) + + const errorListener = jest.fn(console.error) + myXkeysPanel.on('error', errorListener) + + mockWriteStart.mockClear() + mockWriteEnd.mockClear() + + myXkeysPanel.toggleAllBacklights() + + expect(mockWriteStart).toBeCalledTimes(1) + expect(mockWriteEnd).toBeCalledTimes(0) // Should not have been called yet + + // cleanup: + await myXkeysPanel.flush() // waits for all writes to finish + + expect(mockWriteEnd).toBeCalledTimes(1) + + await myXkeysPanel.close() // close the device. + myXkeysPanel.off('error', errorListener) + + expect(errorListener).toHaveBeenCalledTimes(0) + }) + test('flush() with error', async () => { + const hidDevice = { + vendorId: XKeys.vendorId, + productId: 1029, + interface: 0, + path: 'mockPath', + } as HID.Device + + const mockWriteStart = jest.fn() + const mockWriteEnd = jest.fn() + HIDMock.setMockWriteHandler(async (hid, message) => { + mockWriteStart() + await sleep(10) + mockWriteEnd() + // console.log('message', message) + + if (message[0] === 0 && message[1] === 184) { + // toggleAllBacklights + throw new Error('Mock error') + } + + handleXkeysMessages(hid, message) + }) + + const myXkeysPanel = await setupXkeysPanel(hidDevice) + + const errorListener = jest.fn((e) => { + if (`${e}`.includes('Mock error')) return // ignore + console.error(e) + }) + myXkeysPanel.on('error', errorListener) + + mockWriteStart.mockClear() + mockWriteEnd.mockClear() + + myXkeysPanel.toggleAllBacklights() + + expect(mockWriteStart).toBeCalledTimes(1) + expect(errorListener).toBeCalledTimes(0) // Should not have been called yet + + // cleanup: + await myXkeysPanel.flush() // waits for all writes to finish + + expect(errorListener).toBeCalledTimes(1) + errorListener.mockClear() + + await myXkeysPanel.close() // close the device. + myXkeysPanel.off('error', errorListener) + + expect(errorListener).toHaveBeenCalledTimes(0) + }) }) diff --git a/packages/node/src/node-hid-wrapper.ts b/packages/node/src/node-hid-wrapper.ts index 3bd24c6..d69d71c 100644 --- a/packages/node/src/node-hid-wrapper.ts +++ b/packages/node/src/node-hid-wrapper.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/unbound-method */ import { HIDDevice } from '@xkeys-lib/core' import { EventEmitter } from 'events' +import Queue from 'p-queue' import * as HID from 'node-hid' /** @@ -10,6 +11,8 @@ import * as HID from 'node-hid' export class NodeHIDDevice extends EventEmitter implements HIDDevice { static CLOSE_WAIT_TIME = 300 + private readonly writeQueue = new Queue({ concurrency: 1 }) + constructor(private device: HID.HIDAsync) { super() @@ -18,9 +21,13 @@ export class NodeHIDDevice extends EventEmitter implements HIDDevice { } public write(data: number[]): void { - this.device.write(data).catch((err) => { - this.emit('error', err) - }) + this.writeQueue + .add(async () => { + await this.device.write(data) + }) + .catch((err) => { + this.emit('error', err) + }) } public async close(): Promise { @@ -34,6 +41,9 @@ export class NodeHIDDevice extends EventEmitter implements HIDDevice { this.device.removeListener('error', this._handleError) this.device.removeListener('data', this._handleData) } + public async flush(): Promise { + await this.writeQueue.onIdle() + } private _handleData = (data: Buffer) => { this.emit('data', data) diff --git a/packages/webhid/src/web-hid-wrapper.ts b/packages/webhid/src/web-hid-wrapper.ts index f740c46..2e91eb4 100644 --- a/packages/webhid/src/web-hid-wrapper.ts +++ b/packages/webhid/src/web-hid-wrapper.ts @@ -30,6 +30,9 @@ export class WebHIDDevice extends EventEmitter implements CoreHIDDevice { this.emit('error', err) }) } + public async flush(): Promise { + await this.reportQueue.onIdle() + } public async close(): Promise { await this.device.close()