import { Device, devices, HID } from 'node-hid'; import { pathExists } from 'fs-extra'; import * as path from 'path'; import { platform } from 'os'; import { CommandLineArgs, DeviceConnectionState, HalvesInfo, isEqualArray, LogService, UdevRulesInfo } from 'uhk-common'; import { ConfigBufferId, Constants, EepromOperation, enumerationModeIdToProductId, EnumerationModes, KbootCommands, ModuleSlotToI2cAddress, ModuleSlotToId, UsbCommand, UsbVariables } from './constants'; import { bufferToString, getFileContentAsync, getTransferData, isUhkDevice, isUhkZeroInterface, retry, snooze } from './util'; export const BOOTLOADER_TIMEOUT_MS = 5000; /** * HID API wrapper to support unified logging and async write */ export class UhkHidDevice { /** * Internal variable that represent the USB UHK device * @private */ private _prevDevices = []; private _device: HID; private _hasPermission = false; private _udevRulesInfo = UdevRulesInfo.Unknown; constructor(private logService: LogService, private options: CommandLineArgs, private rootDir: string) { } /** * Return true if the app has right to communicate over the USB. * Need only on linux. * If return false need to run {project-root}/rules/setup-rules.sh or * the Agent will ask permission to run at the first time. * @returns {boolean} */ public hasPermission(): boolean { if (this.options.spe) { return false; } try { if (this._hasPermission) { return true; } this.logService.debug('[UhkHidDevice] Devices before check permission:'); const devs = devices(); this.logDevices(devs); const dev = devs.find((x: Device) => isUhkZeroInterface(x) || x.productId === Constants.BOOTLOADER_ID); if (!dev) { return true; } const device = new HID(dev.path); device.close(); this._hasPermission = true; return this._hasPermission; } catch (err) { this.logService.error('[UhkHidDevice] hasPermission', err); } return false; } /** * Return with the USB device communication sate. * @returns {DeviceConnectionState} */ public async getDeviceConnectionStateAsync(): Promise { const devs = devices(); const result: DeviceConnectionState = { bootloaderActive: false, connected: false, zeroInterfaceAvailable: false, hasPermission: this.hasPermission(), udevRulesInfo: await this.getUdevInfoAsync(), halvesInfo: { areHalvesMerged: true, isLeftHalfConnected: true } }; for (const dev of devs) { if (isUhkDevice(dev)) { result.connected = true; } if (isUhkZeroInterface(dev)) { result.zeroInterfaceAvailable = true; } else if (dev.vendorId === Constants.VENDOR_ID && dev.productId === Constants.BOOTLOADER_ID) { result.bootloaderActive = true; } } if (result.connected && result.hasPermission && result.zeroInterfaceAvailable) { result.halvesInfo = await this.getHalvesStates(); } else if (!result.connected) { this._device = undefined; } return result; } /** * Send data to the UHK device and wait for the response. * Throw an error when 1st byte of the response is not 0 * @param {Buffer} buffer * @returns {Promise} */ public async write(buffer: Buffer): Promise { return new Promise((resolve, reject) => { const device = this.getDevice(); if (!device) { return reject(new Error('[UhkHidDevice] Device is not connected')); } device.read((err: any, receivedData: Array) => { if (err) { this.logService.error('[UhkHidDevice] Transfer error: ', err); this.close(); return reject(err); } const logString = bufferToString(receivedData); this.logService.debug('[UhkHidDevice] USB[R]:', logString); if (receivedData[0] !== 0) { return reject(new Error(`Communications error with UHK. Response code: ${receivedData[0]}`)); } return resolve(Buffer.from(receivedData)); }); const sendData = getTransferData(buffer); this.logService.debug('[UhkHidDevice] USB[W]:', bufferToString(sendData).substr(3)); device.write(sendData); }); } public async writeConfigToEeprom(configBufferId: ConfigBufferId): Promise { await this.write(Buffer.from([UsbCommand.LaunchEepromTransfer, EepromOperation.write, configBufferId])); await this.waitUntilKeyboardBusy(); } public async enableUsbStackTest(): Promise { await this.write(Buffer.from([UsbCommand.SetVariable, UsbVariables.testUsbStack, 1])); await this.waitUntilKeyboardBusy(); } /** * Close the communication chanel with UHK Device */ public close(): void { this.logService.debug('[UhkHidDevice] Device communication closing.'); if (!this._device) { return; } this._device.close(); this._device = null; this.logService.debug('[UhkHidDevice] Device communication closed.'); } public async waitUntilKeyboardBusy(): Promise { while (true) { const buffer = await this.write(Buffer.from([UsbCommand.GetDeviceState])); if (buffer[1] === 0) { break; } this.logService.debug('Keyboard is busy, wait...'); await snooze(200); } } public resetDeviceCache(): void { this._prevDevices = []; } async reenumerate(enumerationMode: EnumerationModes): Promise { const reenumMode = EnumerationModes[enumerationMode].toString(); this.logService.debug(`[UhkHidDevice] Start reenumeration, mode: ${reenumMode}`); const message = Buffer.from([ UsbCommand.Reenumerate, enumerationMode, BOOTLOADER_TIMEOUT_MS & 0xff, (BOOTLOADER_TIMEOUT_MS & 0xff << 8) >> 8, (BOOTLOADER_TIMEOUT_MS & 0xff << 16) >> 16, (BOOTLOADER_TIMEOUT_MS & 0xff << 24) >> 24 ]); const enumeratedProductId = enumerationModeIdToProductId[enumerationMode.toString()]; const startTime = new Date(); let jumped = false; while (new Date().getTime() - startTime.getTime() < 20000) { const devs = devices(); this.logService.silly('[UhkHidDevice] reenumeration devices', devs); const inBootloaderMode = devs.some((x: Device) => x.vendorId === Constants.VENDOR_ID && x.productId === enumeratedProductId); if (inBootloaderMode) { this.logService.debug(`[UhkHidDevice] reenumeration devices up`); return; } this.logService.silly(`[UhkHidDevice] Could not find reenumerated device: ${reenumMode}. Waiting...`); await snooze(100); if (!jumped) { const device = this.getDevice(); if (device) { const data = getTransferData(message); this.logService.debug(`[UhkHidDevice] USB[T]: Enumerate device. Mode: ${reenumMode}`); this.logService.debug('[UhkHidDevice] USB[W]:', bufferToString(data).substr(3)); device.write(data); device.close(); jumped = true; } else { this.logService.silly(`[UhkHidDevice] USB[T]: Enumerate device is not ready yet}`); } } } this.logService.error(`[UhkHidDevice] Could not find reenumerated device: ${reenumMode}. Timeout`); throw new Error(`Could not reenumerate as ${reenumMode}`); } async sendKbootCommandToModule(module: ModuleSlotToI2cAddress, command: KbootCommands, maxTry = 1): Promise { let transfer; const moduleName = kbootCommandName(module); this.logService.debug(`[UhkHidDevice] USB[T]: Send KbootCommand ${moduleName} ${KbootCommands[command].toString()}`); if (command === KbootCommands.idle) { transfer = Buffer.from([UsbCommand.SendKbootCommandToModule, command]); } else { transfer = Buffer.from([UsbCommand.SendKbootCommandToModule, command, module]); } await retry(async () => await this.write(transfer), maxTry, this.logService); } async jumpToBootloaderModule(module: ModuleSlotToId): Promise { this.logService.debug(`[UhkHidDevice] USB[T]: Jump to bootloader. Module: ${ModuleSlotToId[module].toString()}`); const transfer = Buffer.from([UsbCommand.JumpToModuleBootloader, module]); await this.write(transfer); } async getHalvesStates(): Promise { const buffer = await this.write(Buffer.from([UsbCommand.GetDeviceState])); return { areHalvesMerged: buffer[2] !== 0, isLeftHalfConnected: buffer[3] !== 0 }; } /** * Return the stored version of HID device. If not exist try to initialize. * @returns {HID} * @private */ private getDevice() { if (!this._device) { this._device = this.connectToDevice(); } return this._device; } /** * Initialize new UHK HID device. * @returns {HID} */ private connectToDevice(): HID { try { const devs = devices(); let compareDevices = devs as any; if (platform() === 'linux') { compareDevices = devs.map(x => ({ productId: x.productId, vendorId: x.vendorId, interface: x.interface })); } if (!isEqualArray(this._prevDevices, compareDevices)) { this.logService.debug('[UhkHidDevice] Available devices:'); this.logDevices(devs); this._prevDevices = compareDevices; } else { this.logService.debug('[UhkHidDevice] Available devices unchanged'); } const dev = devs.find(isUhkZeroInterface); if (!dev) { this.logService.debug('[UhkHidDevice] UHK Device not found:'); return null; } const device = new HID(dev.path); this.logService.debug('[UhkHidDevice] Used device:', JSON.stringify(dev)); return device; } catch (err) { this.logService.error('[UhkHidDevice] Can not create device:', err); } return null; } private logDevices(devs: Array): void { for (const logDevice of devs) { this.logService.debug(JSON.stringify(logDevice)); } } private async getUdevInfoAsync(): Promise { if (this._udevRulesInfo === UdevRulesInfo.Ok) { return UdevRulesInfo.Ok; } if (process.platform === 'win32' || process.platform === 'darwin') { this._udevRulesInfo = UdevRulesInfo.Ok; return UdevRulesInfo.Ok; } if (!(await pathExists('/etc/udev/rules.d/50-uhk60.rules'))) { return UdevRulesInfo.NeedToSetup; } const expectedUdevSettings = await getFileContentAsync(path.join(this.rootDir, 'rules/50-uhk60.rules')); const v2UdevSettings = await getFileContentAsync(path.join(this.rootDir, 'rules/50-uhk60_v2.rules')); const currentUdevSettings = await getFileContentAsync('/etc/udev/rules.d/50-uhk60.rules'); if (isEqualArray(expectedUdevSettings, currentUdevSettings) || isEqualArray(v2UdevSettings, currentUdevSettings)) { this._udevRulesInfo = UdevRulesInfo.Ok; return UdevRulesInfo.Ok; } return UdevRulesInfo.Different; } } function kbootCommandName(module: ModuleSlotToI2cAddress): string { switch (module) { case ModuleSlotToI2cAddress.leftHalf: return 'leftHalf'; case ModuleSlotToI2cAddress.leftModule: return 'leftModule'; case ModuleSlotToI2cAddress.rightModule: return 'rightModule'; default: return 'Unknown'; } }