Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: loupedeck live support #50

Merged
merged 1 commit into from
Sep 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"eventemitter3": "^4.0.7",
"exit-hook": "^2.2.1",
"infinitton-idisplay": "^1.1.2",
"loupedeck": "github:julusian/loupedeck#master",
"meow": "^9.0.0",
"node-hid": "github:julusian/node-hid#v2.1.2-1",
"sharp": "^0.30.7",
Expand Down
1 change: 1 addition & 0 deletions src/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export class CardGenerator {
left: 10,
},
])
.removeAlpha()
.toBuffer()
}
}
226 changes: 226 additions & 0 deletions src/device-types/loupedeck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { LoupedeckDevice, LoupedeckButtonId, LoupedeckKnobId } from 'loupedeck'
import sharp = require('sharp')
import { CompanionSatelliteClient } from '../client'
import { CardGenerator } from '../cards'
import { ImageWriteQueue } from '../writeQueue'
import { DeviceDrawProps, DeviceRegisterProps, WrappedDevice } from './api'

export class LoupedeckWrapper implements WrappedDevice {
readonly #cardGenerator: CardGenerator
readonly #deck: LoupedeckDevice
readonly #deviceId: string

#queueOutputId: number
#queue: ImageWriteQueue

public get deviceId(): string {
return this.#deviceId
}
public get productName(): string {
return `Satellite Loupedeck Live`
}

public constructor(deviceId: string, device: LoupedeckDevice, cardGenerator: CardGenerator) {
this.#deck = device
this.#deviceId = deviceId
this.#cardGenerator = cardGenerator

this.#queueOutputId = 0

this.#cardGenerator

this.#queue = new ImageWriteQueue(async (key: number, buffer: Buffer) => {
if (key > 40) {
return
}

const outputId = this.#queueOutputId

const width = 80
const height = 80

let newbuffer: Buffer | null = null
try {
newbuffer = await sharp(buffer, { raw: { width: 72, height: 72, channels: 3 } })
.resize(width, height)
.raw()
.toBuffer()
} catch (e) {
console.log(`device(${deviceId}): scale image failed: ${e}`)
return
}

// Check if generated image is still valid
if (this.#queueOutputId === outputId) {
try {
// Get offset x/y for key index
const x = (key % 4) * 90
const y = Math.floor(key / 4) * 90

await this.#deck.drawBuffer({
id: 'center',
width,
height,
x: x + (90 - width) / 2,
y: y + (90 - height) / 2,
buffer: newbuffer,
})
} catch (e_1) {
console.error(`device(${deviceId}): fillImage failed: ${e_1}`)
}
}
})
}

getRegisterProps(): DeviceRegisterProps {
return {
keysTotal: 32,
keysPerRow: 8,
bitmaps: true,
colours: true,
text: true,
}
}

async close(): Promise<void> {
this.#queue?.abort()
this.#deck.close()
}
async initDevice(client: CompanionSatelliteClient, status: string): Promise<void> {
const convertButtonId = (id: LoupedeckButtonId | LoupedeckKnobId): number => {
if (!isNaN(Number(id))) {
return 24 + Number(id)
} else if (id === 'circle') {
return 24
} else if (id === 'knobTL') {
return 1
} else if (id === 'knobCL') {
return 9
} else if (id === 'knobBL') {
return 17
} else if (id === 'knobTR') {
return 6
} else if (id === 'knobCR') {
return 14
} else if (id === 'knobBR') {
return 22
} else {
// Discard
return 99
}
}
console.log('Registering key events for ' + this.deviceId)
this.#deck.on('down', ({ id }) => client.keyDown(this.deviceId, convertButtonId(id)))
this.#deck.on('up', ({ id }) => client.keyUp(this.deviceId, convertButtonId(id)))
this.#deck.on('rotate', ({ id, delta }) => {
let id2
if (id === 'knobTL') {
id2 = 0
} else if (id === 'knobCL') {
id2 = 8
} else if (id === 'knobBL') {
id2 = 16
} else if (id === 'knobTR') {
id2 = 7
} else if (id === 'knobCR') {
id2 = 15
} else if (id === 'knobBR') {
id2 = 23
}

if (id2 !== undefined) {
switch (delta) {
case -1:
client.keyUp(this.deviceId, id2)
break
case 1:
client.keyDown(this.deviceId, id2)
break
}
}
})
const translateKeyIndex = (key: number): number => {
const x = key % 4
const y = Math.floor(key / 4)
return y * 8 + x + 2
}
this.#deck.on('touchstart', (data) => {
for (const touch of data.changedTouches) {
if (touch.target.key !== undefined) {
client.keyDown(this.deviceId, translateKeyIndex(touch.target.key))
}
}
})
this.#deck.on('touchend', (data) => {
for (const touch of data.changedTouches) {
if (touch.target.key !== undefined) {
client.keyUp(this.deviceId, translateKeyIndex(touch.target.key))
}
}
})

// Start with blanking it
await this.blankDevice()

await this.showStatus(client.host, status)
}

async deviceAdded(): Promise<void> {
this.#queueOutputId++
}
async setBrightness(percent: number): Promise<void> {
this.#deck.setBrightness(percent / 100)
}
async blankDevice(): Promise<void> {
for (let i = 0; i < 8; i++) {
this.#deck.setButtonColor({
id: i === 0 ? 'circle' : ((i + '') as any),
color: '#0000',
})
}

// await this.#deck.clearPanel()
}
async draw(d: DeviceDrawProps): Promise<void> {
if (d.keyIndex >= 24 && d.keyIndex < 32) {
const index = d.keyIndex - 24
const color = d.color || '#000000'
this.#deck.setButtonColor({
id: index === 0 ? 'circle' : ((index + '') as any),
color,
})
return
}
const x = (d.keyIndex % 8) - 2
const y = Math.floor(d.keyIndex / 8)

if (x >= 0 && x < 4) {
const keyIndex = x + y * 4
if (d.image) {
this.#queue.queue(keyIndex, d.image)
} else {
throw new Error(`Cannot draw for Loupedeck without image`)
}
}
}
async showStatus(hostname: string, status: string): Promise<void> {
// abort and discard current operations
this.#queue?.abort()
this.#queueOutputId++
const outputId = this.#queueOutputId
this.#cardGenerator
.generateBasicCard(360, 270, hostname, status)
.then(async (buffer) => {
if (outputId === this.#queueOutputId) {
// still valid
this.#deck.drawBuffer({
id: 'center',
buffer,
})
}
})
.catch((e) => {
console.error(`Failed to fill device`, e)
})
}
}
2 changes: 1 addition & 1 deletion src/device-types/streamdeck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export class StreamDeckWrapper implements WrappedDevice {
.then(async (buffer) => {
if (outputId === this.#queueOutputId) {
// still valid
await this.#deck.fillPanelBuffer(buffer, { format: 'rgba' })
await this.#deck.fillPanelBuffer(buffer)
}
})
.catch((e) => {
Expand Down
39 changes: 39 additions & 0 deletions src/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { StreamDeckWrapper } from './device-types/streamdeck'
import { QuickKeysWrapper } from './device-types/xencelabs-quick-keys'
import Infinitton = require('infinitton-idisplay')
import { InfinittonWrapper } from './device-types/infinitton'
import { LoupedeckWrapper } from './device-types/loupedeck'
import { listDevices as listLoupedecks, LoupedeckDevice } from 'loupedeck'
import * as HID from 'node-hid'

// Force into hidraw mode
Expand Down Expand Up @@ -38,6 +40,8 @@ export class DeviceManager {
XencelabsQuickKeysManagerInstance.scanDevices().catch((e) => {
console.error(`Quickey scan failed: ${e}`)
})
} else if (dev.deviceDescriptor.idVendor === 0x2ec2) {
this.foundDevice(dev)
}
})
usb.on('detach', (dev) => {
Expand Down Expand Up @@ -220,6 +224,41 @@ export class DeviceManager {
XencelabsQuickKeysManagerInstance.openDevicesFromArray(devices).catch((e) => {
console.error(`Quick keys scan failed: ${e}`)
})

listLoupedecks({ ignoreWebsocket: true })
.then((devs) => {
for (const dev of devs) {
if (dev.type === 'serial' && dev.serialNumber) {
this.tryAddLoupedeck(dev.path, dev.serialNumber)
}
}
})
.catch((e) => {
console.error(`Loupedeck scan failed: ${e}`)
})
}

private async tryAddLoupedeck(path: string, serial: string) {
let ld: LoupedeckDevice | undefined
try {
if (!this.devices.has(serial)) {
console.log(`adding new device: ${path}`)
console.log(`existing = ${JSON.stringify(Array.from(this.devices.keys()))}`)

ld = new LoupedeckDevice({ path, autoConnect: false })
// ld.on('error', (e) => {
// console.error('device error', e)
// this.cleanupDeviceById(serial)
// })
await ld.connect()

const devInfo = new LoupedeckWrapper(serial, ld, this.cardGenerator)
await this.tryAddDeviceInner(serial, devInfo)
}
} catch (e) {
console.log(`Open "${path}" failed: ${e}`)
if (ld) ld.close() //.catch((e) => null)
}
}

private async tryAddStreamdeck(path: string, serial: string) {
Expand Down
Loading