Skip to content

Commit

Permalink
feat: support loupedeck live s
Browse files Browse the repository at this point in the history
  • Loading branch information
Julusian committed Dec 2, 2022
1 parent 69df41d commit f021503
Show file tree
Hide file tree
Showing 5 changed files with 281 additions and 12 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"dependencies": {
"@elgato-stream-deck/node": "^5.7.0",
"@julusian/jpeg-turbo": "^2.0.0",
"@loupedeck/node": "^0.3.0",
"@loupedeck/node": "^0.3.1",
"@xencelabs-quick-keys/node": "^0.4.0",
"electron-about-window": "^1.15.2",
"electron-prompt": "^1.7.0",
Expand Down
261 changes: 261 additions & 0 deletions src/device-types/loupedeck-live-s.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import { LoupedeckDevice, LoupedeckDisplayId, LoupedeckBufferFormat, LoupedeckModelId } from '@loupedeck/node'
import sharp = require('sharp')
import { CompanionSatelliteClient } from '../client'
import { CardGenerator } from '../cards'
import { ImageWriteQueue } from '../writeQueue'
import { DeviceDrawProps, DeviceRegisterProps, WrappedDevice } from './api'

const screenWidth = 450
const screenHeight = 270
const keyPadding = 5

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

#queueOutputId: number
#isShowingCard = true
#queue: ImageWriteQueue

public get deviceId(): string {
return this.#deviceId
}
public get productName(): string {
return this.#deck.modelName
}

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

if (device.modelId !== LoupedeckModelId.LoupedeckLiveS) throw new Error('Incorrect model passed to wrapper!')

this.#queueOutputId = 0

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

const outputId = this.#queueOutputId

const width = 80
const height = 80
const boundaryWidth = width + keyPadding * 2
const boundaryHeight = height + keyPadding * 2

let newbuffer: Buffer
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 % 5) * boundaryWidth
const y = Math.floor(key / 5) * boundaryHeight

if (this.#isShowingCard) {
this.#isShowingCard = false

// Do a blank of the whole panel before drawing a button, so that there isnt any bleed
await this.blankDevice(true)
}

await this.#deck.drawBuffer(
LoupedeckDisplayId.Center,
newbuffer,
LoupedeckBufferFormat.RGB,
width,
height,
x + keyPadding,
y + keyPadding
)
} catch (e_1) {
console.error(`device(${deviceId}): fillImage failed: ${e_1}`)
}
}
})
}

getRegisterProps(): DeviceRegisterProps {
return {
keysTotal: 21,
keysPerRow: 7,
bitmaps: true,
colours: true,
text: false,
}
}

async close(): Promise<void> {
this.#queue?.abort()
this.#deck.close()
}
async initDevice(client: CompanionSatelliteClient, status: string): Promise<void> {
const convertButtonId = (type: 'button' | 'rotary', id: number): number => {
if (type === 'button') {
// return 24 + id
switch (id) {
case 0:
return 14
case 1:
return 6
case 2:
return 13
case 3:
return 20
}
} else if (type === 'rotary') {
switch (id) {
case 0:
return 0
case 1:
return 7
}
}

// Discard
return 99
}
console.log('Registering key events for ' + this.deviceId)
this.#deck.on('down', (info) => client.keyDown(this.deviceId, convertButtonId(info.type, info.index)))
this.#deck.on('up', (info) => client.keyUp(this.deviceId, convertButtonId(info.type, info.index)))
this.#deck.on('rotate', (info, delta) => {
if (info.type !== 'rotary') return

const id2 = convertButtonId(info.type, info.index)
if (id2 < 90) {
if (delta < 0) {
if (client.useCombinedEncoders) {
client.rotateLeft(this.deviceId, id2)
} else {
client.keyUp(this.deviceId, id2)
}
} else if (delta > 0) {
if (client.useCombinedEncoders) {
client.rotateRight(this.deviceId, id2)
} else {
client.keyDown(this.deviceId, id2)
}
}
}
})
const translateKeyIndex = (key: number): number => {
const x = key % 5
const y = Math.floor(key / 5)
return y * 7 + x + 1
}
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(skipButtons?: boolean): Promise<void> {
await this.#deck.blankDevice(true, !skipButtons)
}
async draw(d: DeviceDrawProps): Promise<void> {
let buttonIndex: number | undefined
switch (d.keyIndex) {
case 14:
buttonIndex = 0
break
case 6:
buttonIndex = 1
break
case 13:
buttonIndex = 2
break
case 20:
buttonIndex = 3
break
}
if (buttonIndex !== undefined) {
const red = d.color ? parseInt(d.color.substr(1, 2), 16) : 0
const green = d.color ? parseInt(d.color.substr(3, 2), 16) : 0
const blue = d.color ? parseInt(d.color.substr(5, 2), 16) : 0

this.#deck.setButtonColor({
id: buttonIndex,
red,
green,
blue,
})

return
}

const x = (d.keyIndex % 7) - 1
const y = Math.floor(d.keyIndex / 7)

if (x >= 0 && x < 5) {
const keyIndex = x + y * 5
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> {
const width = screenWidth - keyPadding * 2
const height = screenHeight - keyPadding * 2

// abort and discard current operations
this.#queue?.abort()
this.#queueOutputId++
const outputId = this.#queueOutputId
this.#cardGenerator
.generateBasicCard(width, height, hostname, status)
.then(async (buffer) => {
if (outputId === this.#queueOutputId) {
console.log('draw buffer')
this.#isShowingCard = true
// still valid
await this.#deck.drawBuffer(
LoupedeckDisplayId.Center,
buffer,
LoupedeckBufferFormat.RGB,
width,
height,
keyPadding,
keyPadding
)
}
})
.catch((e) => {
console.error(`Failed to fill device`, e)
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const screenWidth = 360
const screenHeight = 270
const keyPadding = 5

export class LoupedeckWrapper implements WrappedDevice {
export class LoupedeckLiveWrapper implements WrappedDevice {
readonly #cardGenerator: CardGenerator
readonly #deck: LoupedeckDevice
readonly #deviceId: string
Expand Down Expand Up @@ -97,7 +97,7 @@ export class LoupedeckWrapper implements WrappedDevice {
keysPerRow: 8,
bitmaps: true,
colours: true,
text: true,
text: false,
}
}

Expand Down
18 changes: 13 additions & 5 deletions src/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +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 { LoupedeckLiveWrapper } from './device-types/loupedeck-live'
import { LoupedeckLiveSWrapper } from './device-types/loupedeck-live-s'
import * as HID from 'node-hid'
import { openLoupedeck, listLoupedecks, LoupedeckDevice, LoupedeckModelId } from '@loupedeck/node'

Expand Down Expand Up @@ -233,7 +234,9 @@ export class DeviceManager {
(dev.model === LoupedeckModelId.LoupedeckLive ||
dev.model === LoupedeckModelId.RazerStreamController)
) {
this.tryAddLoupedeckLive(dev.path, dev.serialNumber)
this.tryAddLoupedeckLive(dev.path, dev.serialNumber, false)
} else if (dev.serialNumber && dev.model === LoupedeckModelId.LoupedeckLiveS) {
this.tryAddLoupedeckLive(dev.path, dev.serialNumber, true)
}
}
})
Expand All @@ -242,7 +245,7 @@ export class DeviceManager {
})
}

private async tryAddLoupedeckLive(path: string, serial: string) {
private async tryAddLoupedeckLive(path: string, serial: string, isLiveS: boolean) {
let ld: LoupedeckDevice | undefined
try {
if (!this.devices.has(serial)) {
Expand All @@ -255,8 +258,13 @@ export class DeviceManager {
this.cleanupDeviceById(serial)
})

const devInfo = new LoupedeckWrapper(serial, ld, this.cardGenerator)
await this.tryAddDeviceInner(serial, devInfo)
if (isLiveS) {
const devInfo = new LoupedeckLiveSWrapper(serial, ld, this.cardGenerator)
await this.tryAddDeviceInner(serial, devInfo)
} else {
const devInfo = new LoupedeckLiveWrapper(serial, ld, this.cardGenerator)
await this.tryAddDeviceInner(serial, devInfo)
}
}
} catch (e) {
console.log(`Open "${path}" failed: ${e}`)
Expand Down
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,10 @@
node-addon-api "^5.0.0"
pkg-prebuilds "~0.1.0"

"@loupedeck/node@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@loupedeck/node/-/node-0.3.0.tgz#5239f3ae3ec8a7d9efa3a65a41e2f007fb17ad1e"
integrity sha512-nWVKUr0jGxU/fJAfY73hp+FsodsvKfAsjAI1QxdJ6VmHmaAmz6RkEvMq/c7E29K3Dr8Us+vU1UA0pBNUPD6BHQ==
"@loupedeck/node@^0.3.1":
version "0.3.1"
resolved "https://registry.yarnpkg.com/@loupedeck/node/-/node-0.3.1.tgz#8f4c6abdf3b3ad909c7a0435df1e7b3796fb5e52"
integrity sha512-2WVnjmWROAIXUUam/yeGqP47AdsOExocBx3GOIwfxKh8tm99BFOl+xukb0pGBOROK43aqM0LEyWGJOs5vtCF4g==
dependencies:
eventemitter3 "^4.0.7"
p-queue "^6.6.2"
Expand Down

0 comments on commit f021503

Please sign in to comment.