UNPKG

hyperx-cloud-flight-wireless

Version:

Enhanced module for interfacing with HyperX Cloud Flight Wireless with robust reconnection handling

429 lines (346 loc) 11.6 kB
const HID = require('node-hid') const EventEmitter = require('eventemitter3') const VENDOR_ID = 2385 const PRODUCT_ID = 5828 // usage pages // 65472 - power state/muting/unmuting - byte length: 2 // 12 - volume up/down - byte length: 5 // 65363 - "status" - byte length: 20 module.exports = ({ debug = false, updateDelay = 5 * 1000 * 60 } = {}) => { const platform = process.platform if (platform == 'win32' || platform == 'win64') { HID.setDriverType('libusb') } const emitter = new EventEmitter() // Reconnect variables let reconnectAttempts = 0 let maxReconnectAttempts = 10 let reconnectBackoff = 1000 // Start with 1 second, will increase let maxBackoffTime = 5 * 60 * 1000 // 5 minutes in milliseconds let reconnectTimer = null // Keep track of all created device handles let deviceHandles = [] let bootstrapDevice = null let interval = null let isConnected = false let powerState = 'unknown' // Function to get fresh device list function getDevices() { return HID.devices().filter( (d) => d.vendorId === VENDOR_ID && d.productId === PRODUCT_ID, ) } // Find devices initially let devices = getDevices() if (devices.length === 0) { throw new Error('HyperX Cloud Flight Wireless was not found') } // Function to close all devices function closeAllDevices() { if (debug) console.log('Closing all device handles') deviceHandles.forEach((device) => { try { device.close() } catch (err) { if (debug) console.error('Error closing device:', err) } }) deviceHandles = [] bootstrapDevice = null } // Function to completely reinitialize all devices function reinitializeDevices() { if (debug) console.log( `Reinitializing all devices (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})`, ) // Close existing devices first closeAllDevices() // Get fresh device list devices = getDevices() if (devices.length === 0) { reconnectAttempts++ // Calculate backoff time with exponential increase, but cap at maximum let delay = Math.min( reconnectBackoff * Math.pow(1.5, reconnectAttempts), maxBackoffTime, ) // If we've reached max attempts, just use the maximum backoff time for all future attempts if (reconnectAttempts >= maxReconnectAttempts) { if (debug) console.log( 'Max reconnect attempts reached. Continuing with 5-minute interval checks.', ) emitter.emit( 'disconnected', new Error( 'Device disconnected. Checking every 5 minutes for reconnection.', ), ) delay = maxBackoffTime } if (debug) console.log( `Device not found. Trying again in ${Math.round(delay / 1000)} seconds.`, ) emitter.emit( 'error', new Error( 'HyperX Cloud Flight Wireless was not found during reinitialize', ), ) // Clear any existing timers if (reconnectTimer) { clearTimeout(reconnectTimer) } // Schedule next attempt with backoff reconnectTimer = setTimeout(reinitializeDevices, delay) return false } // Device found! Reset reconnect attempts reconnectAttempts = 0 // Create fresh connections to all devices initializeDevices() // Also restart bootstrap interval if (interval) { clearInterval(interval) } interval = setInterval(bootstrap, updateDelay) // Clear reconnect timer if it exists if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null } emitter.emit('connected') return true } function bootstrap() { if (!interval && powerState !== 'off') { interval = setInterval(bootstrap, updateDelay) } // Fresh discovery of bootstrap device each time try { if (!bootstrapDevice) { const bootstrapDeviceInfo = devices.find( (d) => d.usagePage === 65363 && d.usage === 771, ) if (!bootstrapDeviceInfo) { if (debug) console.log('Bootstrap device not found, trying to reinitialize') return reinitializeDevices() } try { bootstrapDevice = new HID.HID(bootstrapDeviceInfo.path) deviceHandles.push(bootstrapDevice) } catch (e) { if (debug) console.error('Error creating bootstrap device:', e) emitter.emit('error', e) return false } } const buffer = Buffer.from([ 0x21, 0xff, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]) try { bootstrapDevice.write(buffer) isConnected = true } catch (e) { if (debug) console.error('Error writing to bootstrap device:', e) bootstrapDevice = null emitter.emit('error', e) return false } return true } catch (e) { if (debug) console.error('Error in bootstrap:', e) emitter.emit('error', e) return false } } function initializeDevices() { devices.forEach((deviceInfo) => { try { const device = new HID.HID(deviceInfo.path) deviceHandles.push(device) device.on('error', (err) => { if (debug) console.error('Device error:', err) const errorStr = String(err).toLowerCase() const isDisconnectError = errorStr.includes('could not read') || errorStr.includes('disconnect') || errorStr.includes('not found') || errorStr.includes('access denied') if (isDisconnectError && !reconnectTimer) { // Only schedule reconnection if one isn't already in progress reconnectTimer = setTimeout(reinitializeDevices, reconnectBackoff) } emitter.emit('error', err) }) device.on('data', (data) => { if (debug) { console.log(new Date(), data, `length: ${data.length}`) for (let byte of data) { console.log(byte) } } switch (data.length) { case 0x2: if (data[0] === 0x64 && data[1] == 0x3) { powerState = 'off' // Clear interval but maintain the device handles if (interval) { clearInterval(interval) interval = null } return emitter.emit('power', 'off') } if (data[0] === 0x64 && data[1] == 0x1) { powerState = 'on' // When power comes back on, reinitialize everything setTimeout(() => { reinitializeDevices() emitter.emit('power', 'on') }, 500) return } const isMuted = data[0] === 0x65 && data[1] === 0x04 emitter.emit('muted', isMuted) break case 0x5: const volumeDirectionValue = data[1] const volumeDirection = volumeDirectionValue === 0x01 ? 'up' : volumeDirectionValue === 0x02 ? 'down' : null if (!volumeDirection) { return } emitter.emit('volume', volumeDirection) break case 0xf: case 0x14: const chargeState = data[3] const magicValue = data[4] || chargeState function calculatePercentage() { if (chargeState === 0x10) { emitter.emit('charging', magicValue >= 20) if (magicValue <= 11) { return 100 } } if (chargeState === 0xf) { if (magicValue >= 130) { return 100 } if (magicValue < 130 && magicValue >= 120) { return 95 } if (magicValue < 120 && magicValue >= 100) { return 90 } if (magicValue < 100 && magicValue >= 70) { return 85 } if (magicValue < 70 && magicValue >= 50) { return 80 } if (magicValue < 50 && magicValue >= 20) { return 75 } if (magicValue < 20 && magicValue > 0) { return 70 } } if (chargeState === 0xe) { if (magicValue < 250 && magicValue > 240) { return 65 } if (magicValue < 240 && magicValue >= 220) { return 60 } if (magicValue < 220 && magicValue >= 208) { return 55 } if (magicValue < 208 && magicValue >= 200) { return 50 } if (magicValue < 200 && magicValue >= 190) { return 45 } if (magicValue < 190 && magicValue >= 180) { return 40 } if (magicValue < 179 && magicValue >= 169) { return 35 } if (magicValue < 169 && magicValue >= 159) { return 30 } if (magicValue < 159 && magicValue >= 148) { return 25 } if (magicValue < 148 && magicValue >= 119) { return 20 } if (magicValue < 119 && magicValue >= 90) { return 15 } if (magicValue < 90) { return 10 } } return null } const percentage = calculatePercentage() if (percentage) { emitter.emit('battery', percentage) } break default: emitter.emit('unknown', data) } }) } catch (err) { if (debug) console.error('Error initializing device:', err) emitter.emit('error', err) } }) } // Start the initial connection initializeDevices() bootstrap() // Add method to clear listeners - FIXED to avoid recursion emitter.clearListeners = function () { this.removeAllListeners('battery') this.removeAllListeners('power') this.removeAllListeners('muted') this.removeAllListeners('volume') this.removeAllListeners('charging') this.removeAllListeners('error') this.removeAllListeners('unknown') this.removeAllListeners('close') this.removeAllListeners('connected') this.removeAllListeners('disconnected') } // Add a close method to the emitter emitter.close = function () { if (interval) { clearInterval(interval) interval = null } if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null } closeAllDevices() this.clearListeners() } // Handle close event emitter.on('close', () => { emitter.close() }) return emitter }