Files
PackControl/node_modules/loupedeck/device.js
2026-02-27 22:46:14 +01:00

386 lines
14 KiB
JavaScript

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!')
}
}