import { Emitter as EventEmitter } from 'strict-event-emitter' import rgba from 'color-rgba' let SerialConnection, WSConnection // Only import when in a browser environment if (typeof navigator !== 'undefined' && navigator.serial || import.meta.env?.PROD) { SerialConnection = (await import('./connections/web-serial.js')).default } else { SerialConnection = (await import('./connections/serial.js')).default WSConnection = (await import('./connections/ws.js')).default } let canvasModule try { canvasModule = await import('canvas') // eslint-disable-next-line } catch (e) { // No canvas is ok, do check in `drawCanvas` } import { BUTTONS, COMMANDS, DEFAULT_RECONNECT_INTERVAL, HAPTIC, MAX_BRIGHTNESS, } from './constants.js' import { rgba2rgb565 } from './util.js' export class LoupedeckDevice extends EventEmitter { static async list({ ignoreSerial = false, ignoreWebsocket = false } = {}) { const ps = [] if (!ignoreSerial) ps.push(SerialConnection.discover()) if (!ignoreWebsocket && WSConnection) ps.push(WSConnection.discover()) // Run them in parallel const rawDevices = await Promise.all(ps) return rawDevices.flat() } keySize = 90 constructor({ host, path, autoConnect = true, reconnectInterval = DEFAULT_RECONNECT_INTERVAL } = {}) { super() this.transactionID = 0 this.touches = {} this.handlers = { [COMMANDS.BUTTON_PRESS]: this.onButton.bind(this), [COMMANDS.KNOB_ROTATE]: this.onRotate.bind(this), [COMMANDS.SERIAL]: buff => buff.toString().trim(), [COMMANDS.TICK]: () => {}, [COMMANDS.TOUCH]: this.onTouch.bind(this, 'touchmove'), [COMMANDS.TOUCH_END]: this.onTouch.bind(this, 'touchend'), [COMMANDS.VERSION]: buff => `${buff[0]}.${buff[1]}.${buff[2]}`, [COMMANDS.TOUCH_CT]: this.onTouch.bind(this, 'touchmove'), [COMMANDS.TOUCH_END_CT]: this.onTouch.bind(this, 'touchend'), } // Track pending transactions this.pendingTransactions = {} // How long between reconnect attempts this.reconnectInterval = reconnectInterval // Host for websocket connections this.host = host // Path for serial connections this.path = path // Automatically connect? if (autoConnect) this._connectBlind() } close() { if (!this.connection) return return this.connection.close() } async connect() { // Explicitly asked for a serial connection (V0.2.X) if (this.path) this.connection = new SerialConnection({ path: this.path }) // Explicitly asked for a websocket connection (V0.1.X) else if (this.host) this.connection = new WSConnection({ host: this.host }) // Autodiscover else { const devices = await this.constructor.list() if (devices.length > 0) { const { connectionType, ...args } = devices[0] this.connection = new connectionType(args) } if (!this.connection) { return Promise.reject(this.onDisconnect(new Error('No devices found'))) } } this.connection.on('connect', this.onConnect.bind(this)) this.connection.on('message', this.onReceive.bind(this)) this.connection.on('disconnect', this.onDisconnect.bind(this)) return this.connection.connect() } _connectBlind() { return this.connect().catch(() => {}) } // Draw an arbitrary buffer to the device // Buffer format must be 16bit 5-6-5 (LE, except BE for the Loupedeck CT Knob screen) async drawBuffer({ id, width, height, x = 0, y = 0, autoRefresh = true }, buffer) { const displayInfo = this.displays[id] if (!displayInfo) throw new Error(`Display '${id}' is not available on this device!`) if (!width) width = displayInfo.width if (!height) height = displayInfo.height if (displayInfo.offset) { x += displayInfo.offset[0] y += displayInfo.offset[1] } const pixelCount = width * height * 2 if (buffer.length !== pixelCount) { throw new Error(`Expected buffer length of ${pixelCount}, got ${buffer.length}!`) } // Header with x/y/w/h and display ID const header = Buffer.alloc(8) header.writeUInt16BE(x, 0) header.writeUInt16BE(y, 2) header.writeUInt16BE(width, 4) header.writeUInt16BE(height, 6) // Write to frame buffer await this.send(COMMANDS.FRAMEBUFF, Buffer.concat([displayInfo.id, header, buffer])) // Draw to display if (autoRefresh) await this.refresh(id) } // Create a canvas with correct dimensions and pass back for drawing drawCanvas({ id, width, height, ...args }, cb) { const displayInfo = this.displays[id] if (!displayInfo) throw new Error(`Display '${id}' is not available on this device!`) if (!width) width = displayInfo.width if (!height) height = displayInfo.height if (!canvasModule || !canvasModule.createCanvas) { throw new Error('Using callbacks requires the `canvas` library to be installed. Install it using `npm install canvas`.') } const canvas = canvasModule.createCanvas(width, height) const ctx = canvas.getContext('2d', { pixelFormat: 'RGB16_565' }) // Loupedeck uses 16-bit (5-6-5) LE RGB colors cb(ctx, width, height) let buffer // If using NodeJS canvas package if (canvas.toBuffer) { buffer = canvas.toBuffer('raw') // If using browser canvas API } else { const imageData = ctx.getImageData(0, 0, width, height) const rgba = imageData.data // Convert from RGBA to RGB16_565 buffer = rgba2rgb565(rgba, width * height) } // Swap endianness depending on display if (displayInfo.endianness === 'be') buffer.swap16() return this.drawBuffer({ id, width, height, ...args }, buffer) } // Draw to a specific key index (0-11 on Live, 0-14 on Live S) drawKey(index, cb) { // Get offset x/y for key index if (index < 0 || index >= this.columns * this.rows) throw new Error(`Key ${index} is not a valid key`) const width = this.keySize const height = this.keySize const x = this.visibleX[0] + index % this.columns * width const y = Math.floor(index / this.columns) * height return this[cb instanceof Buffer ? 'drawBuffer' : 'drawCanvas']({ id: 'center', x, y, width, height }, cb) } // Draw to a specific screen drawScreen(id, cb) { return this[cb instanceof Buffer ? 'drawBuffer' : 'drawCanvas']({ id }, cb) } async getInfo() { if (!this.connection || !this.connection.isReady()) throw new Error('Not connected!') return { serial: await this.send(COMMANDS.SERIAL), version: await this.send(COMMANDS.VERSION) } } onButton(buff) { if (buff.length < 2) return const id = BUTTONS[buff[0]] const event = buff[1] === 0x00 ? 'down' : 'up' this.emit(event, { id }) } onConnect(info) { this.emit('connect', info) } onDisconnect(error) { this.emit('disconnect', error) clearTimeout(this._reconnectTimer) this.connection = null // Normal disconnect, do not reconnect if (!error) return // Reconnect if desired if (this.reconnectInterval) { this._reconnectTimer = setTimeout(this._connectBlind.bind(this), this.reconnectInterval) } return error.message } onReceive(buff) { const msgLength = buff[0] const handler = this.handlers[buff[1]] const transactionID = buff[2] const response = handler ? handler(buff.slice(3, msgLength)) : buff const resolver = this.pendingTransactions[transactionID] if (resolver) resolver(response) return response } onRotate(buff) { const id = BUTTONS[buff[0]] const delta = buff.readInt8(1) this.emit('rotate', { id, delta }) } onTouch(event, buff) { const x = buff.readUInt16BE(1) const y = buff.readUInt16BE(3) const id = buff[5] // Create touch const touch = { x, y, id, target: this.getTarget(x, y, id) } // End touch, remove from local cache if (event === 'touchend') { delete this.touches[touch.id] } else { // First time seeing this touch, emit touchstart instead of touchmove if (!this.touches[touch.id]) event = 'touchstart' this.touches[touch.id] = touch } this.emit(event, { touches: Object.values(this.touches), changedTouches: [touch] }) } // Display the current framebuffer refresh(id) { const displayInfo = this.displays[id] return this.send(COMMANDS.DRAW, displayInfo.id) } send(command, data = Buffer.alloc(0)) { if (!this.connection || !this.connection.isReady()) return this.transactionID = (this.transactionID + 1) % 256 // Skip transaction ID's of zero since the device seems to ignore them if (this.transactionID === 0) this.transactionID++ const header = Buffer.alloc(3) header[0] = Math.min(3 + data.length, 0xff) header[1] = command header[2] = this.transactionID const packet = Buffer.concat([header, data]) this.connection.send(packet) return new Promise(res => { this.pendingTransactions[this.transactionID] = res }) } setBrightness(value) { const byte = Math.max(0, Math.min(MAX_BRIGHTNESS, Math.round(value * MAX_BRIGHTNESS))) return this.send(COMMANDS.SET_BRIGHTNESS, Buffer.from([byte])) } setButtonColor({ id, color }) { const key = Object.keys(BUTTONS).find(k => BUTTONS[k] === id) if (!key) throw new Error(`Invalid button ID: ${id}`) const [r, g, b] = rgba(color) const data = Buffer.from([key, r, g, b]) return this.send(COMMANDS.SET_COLOR, data) } vibrate(pattern = HAPTIC.SHORT) { return this.send(COMMANDS.SET_VIBRATION, Buffer.from([pattern])) } } export class LoupedeckLive extends LoupedeckDevice { static productId = 0x0004 static vendorId = 0x2ec2 buttons = [0, 1, 2, 3, 4, 5, 6, 7] knobs = ['knobCL', 'knobCR', 'knobTL', 'knobTR', 'knobBL', 'knobBR'] columns = 4 // All displays are addressed as the same screen // So we add offsets displays = { center: { id: Buffer.from('\x00M'), width: 360, height: 270, offset: [60, 0] }, left: { id: Buffer.from('\x00M'), width: 60, height: 270 }, right: { id: Buffer.from('\x00M'), width: 60, height: 270, offset: [420, 0] }, } rows = 3 type = 'Loupedeck Live' visibleX = [0, 480] // Determine touch target based on x/y position getTarget(x, y) { if (x < this.displays.left.width) return { screen: 'left' } if (x >= this.displays.left.width + this.displays.center.width) return { screen: 'right' } const column = Math.floor((x - this.displays.left.width) / this.keySize) const row = Math.floor(y / this.keySize) const key = row * this.columns + column return { screen: 'center', key } } } export class LoupedeckCT extends LoupedeckLive { static productId = 0x0003 buttons = [0, 1, 2, 3, 4, 5, 6, 7, 'home', 'enter', 'undo', 'save', 'keyboard', 'fnL', 'a', 'b', 'c', 'd', 'fnR', 'e'] displays = { center: { id: Buffer.from('\x00A'), width: 360, height: 270 }, // "A" left: { id: Buffer.from('\x00L'), width: 60, height: 270 }, // "L" right: { id: Buffer.from('\x00R'), width: 60, height: 270 }, // "R" knob: { id: Buffer.from('\x00W'), width: 240, height: 240, endianness: 'be' }, // "W" } type = 'Loupedeck CT' // Determine touch target based on x/y position getTarget(x, y, id) { if (id === 0) return { screen: 'knob' } return super.getTarget(x, y) } } export class LoupedeckLiveS extends LoupedeckDevice { static productId = 0x0006 static vendorId = 0x2ec2 buttons = [0, 1, 2, 3] knobs = ['knobCL', 'knobTL'] columns = 5 displays = { center: { id: Buffer.from('\x00M'), width: 480, height: 270 }, } rows = 3 type = 'Loupedeck Live S' visibleX = [15, 465] // Determine touch target based on x/y position getTarget(x, y) { if (x < this.visibleX[0] || x >= this.visibleX[1]) return {} const column = Math.floor((x - this.visibleX[0]) / this.keySize) const row = Math.floor(y / this.keySize) const key = row * this.columns + column return { screen: 'center', key } } } export class RazerStreamController extends LoupedeckLive { static productId = 0x0d06 static vendorId = 0x1532 type = 'Razer Stream Controller' } export class RazerStreamControllerX extends LoupedeckDevice { static productId = 0x0d09 static vendorId = 0x1532 type = 'Razer Stream Controller X' buttons = [] columns = 5 displays = { center: { id: Buffer.from('\x00M'), width: 480, height: 288 }, } rows = 3 visibleX = [0, 480] keySize = 96 // Emit an extra touchstart event since we are touching keys onButton(buff) { super.onButton(buff) const event = buff[1] === 0x00 ? 'touchstart' : 'touchend' const key = BUTTONS[buff[0]] const row = Math.floor(key / this.columns) const col = key % this.columns const touch = { id: 0, // Add half so touch is in the center of the key x: (col + 0.5) * this.keySize, y: (row + 0.5) * this.keySize, target: { key } } this.emit(event, { touches: event === 'touchstart' ? [touch] : [], changedTouches: [touch], }) } // eslint-disable-next-line class-methods-use-this setButtonColor() { throw new Error('Setting key color not available on this device!') } // eslint-disable-next-line class-methods-use-this vibrate() { throw new Error('Vibration not available on this device!') } }