Skip to content

Commit

Permalink
feat: streamdeck plus (#59)
Browse files Browse the repository at this point in the history
  • Loading branch information
Julusian authored Nov 15, 2022
1 parent 45f6137 commit 37479d8
Show file tree
Hide file tree
Showing 31 changed files with 542 additions and 92 deletions.
1 change: 1 addition & 0 deletions .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
test:
name: Test on node ${{ matrix.node-version }} and ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 15

strategy:
fail-fast: false
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", MODE:="666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", MODE:="666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006d", MODE:="666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0080", MODE:="666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0084", MODE:="666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE:="666", GROUP="plugdev"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE:="666", GROUP="plugdev"
KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", MODE:="666", GROUP="plugdev"
KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", MODE:="666", GROUP="plugdev"
KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", MODE:="666", GROUP="plugdev"
KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006d", MODE:="666", GROUP="plugdev"
KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0080", MODE:="666", GROUP="plugdev"
KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0084", MODE:="666", GROUP="plugdev"
KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE:="666", GROUP="plugdev"
KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE:="666", GROUP="plugdev"
```
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"graduate": "yarn bump --conventional-graduate",
"publish2": "yarn build && lerna publish from-package --pre-dist-tag next",
"validate:dependencies": "yarn audit && yarn license-validate",
"license-validate": "yarn sofie-licensecheck --allowPackages \"@elgato-stream-deck/webhid-demo@$(node -p \"require('./packages/webhid-demo/package.json').version\");caniuse-lite@1.0.30001370\""
"license-validate": "yarn sofie-licensecheck --allowPackages \"@elgato-stream-deck/webhid-demo@$(node -p \"require('./packages/webhid-demo/package.json').version\");caniuse-lite@1.0.30001427\""
},
"lint-staged": {
"*.{css,json,md,scss}": [
Expand Down
26 changes: 16 additions & 10 deletions packages/core/src/__tests__/test.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
/* eslint-disable @typescript-eslint/unbound-method */

import { readFixtureJSON } from './helpers'
import { DEVICE_MODELS, StreamDeck } from '../'
import { DeviceModelId, OpenStreamDeckOptions } from '../models'
import { DeviceModelId, DEVICE_MODELS, StreamDeck } from '../'
import { OpenStreamDeckOptions } from '../models'
import { DummyHID } from './hid'
import { EncodeJPEGHelper } from '../models/base'

Expand All @@ -25,7 +25,13 @@ function openStreamDeck(path: string, deviceModel: DeviceModelId, userOptions?:
}

const device = new DummyHID(path, encodeJpegMock)
return new model.class(device, options || {})
return new model.class(
device,
options || {
useOriginalKeyOrder: false,
encodeJPEG: undefined,
}
)
}

function runForDevice(path: string, model: DeviceModelId): void {
Expand Down Expand Up @@ -374,7 +380,7 @@ describe('StreamDeck', () => {

const device = getDevice()
// prettier-ignore
device.emit('input', Buffer.from([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
device.emit('input', Buffer.from([ 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
// prettier-ignore
device.emit('input', Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))

Expand Down Expand Up @@ -607,7 +613,7 @@ describe('StreamDeck XL', () => {
const device = getDevice()
device.encodeJPEG.mockImplementationOnce(async (buffer: Buffer) => {
const start = buffer.length / 8
return buffer.slice(start, start * 2)
return buffer.subarray(start, start * 2)
})

const writeFn: jest.Mock<Promise<void>, [Buffer[]]> = (device.sendReports = jest.fn())
Expand All @@ -625,9 +631,9 @@ describe('StreamDeck XL', () => {

const device = getDevice()
// prettier-ignore
device.emit('input', Buffer.from([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
device.emit('input', Buffer.from([0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
// prettier-ignore
device.emit('input', Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
device.emit('input', Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))

expect(downSpy).toHaveBeenCalledTimes(1)
expect(upSpy).toHaveBeenCalledTimes(1)
Expand Down Expand Up @@ -660,7 +666,7 @@ describe('StreamDeck Original V2', () => {
const device = getDevice()
device.encodeJPEG.mockImplementationOnce(async (buffer: Buffer) => {
const start = buffer.length / 8
return buffer.slice(start, start * 2)
return buffer.subarray(start, start * 2)
})

const writeFn: jest.Mock<Promise<void>, [Buffer[]]> = (device.sendReports = jest.fn())
Expand All @@ -678,9 +684,9 @@ describe('StreamDeck Original V2', () => {

const device = getDevice()
// prettier-ignore
device.emit('input', Buffer.from([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
device.emit('input', Buffer.from([0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
// prettier-ignore
device.emit('input', Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
device.emit('input', Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))

expect(downSpy).toHaveBeenCalledTimes(1)
expect(upSpy).toHaveBeenCalledTimes(1)
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export interface HIDDevice {
dataKeyOffset?: number

on(event: 'error', handler: (data: any) => void): this
on(event: 'input', handler: (keys: number[]) => void): this
on(event: 'input', handler: (keys: Uint8Array) => void): this

close(): Promise<void>

Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/models/id.ts → packages/core/src/id.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export type KeyIndex = number

export type EncoderIndex = number

export enum DeviceModelId {
ORIGINAL = 'original',
ORIGINALV2 = 'originalv2',
Expand All @@ -9,4 +11,5 @@ export enum DeviceModelId {
XL = 'xl',
XLV2 = 'xlv2',
PEDAL = 'pedal',
PLUS = 'plus',
}
22 changes: 12 additions & 10 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HIDDevice } from './device'
import { DeviceModelId } from './id'
import {
DeviceModelId,
StreamDeckMini,
StreamDeckMiniV2,
StreamDeckOriginal,
Expand All @@ -10,18 +10,14 @@ import {
StreamDeckXLV2,
StreamDeckPedal,
OpenStreamDeckOptions,
StreamDeck,
} from './models'
import { StreamDeckPlus } from './models/plus'
import { StreamDeck } from './types'

export * from './types'
export * from './id'
export { HIDDevice } from './device'
export {
DeviceModelId,
KeyIndex,
StreamDeck,
OpenStreamDeckOptions,
FillImageOptions,
FillPanelOptions,
} from './models'
export { OpenStreamDeckOptions } from './models'
export { StreamDeckProxy } from './proxy'

/** Elgato vendor id */
Expand Down Expand Up @@ -71,6 +67,12 @@ export const DEVICE_MODELS: DeviceModelSpec[] = [
productId: 0x0080,
class: StreamDeckOriginalMK2,
},
{
id: DeviceModelId.PLUS,
type: DeviceModelType.STREAMDECK,
productId: 0x0084,
class: StreamDeckPlus,
},
{
id: DeviceModelId.PEDAL,
type: DeviceModelType.PEDAL,
Expand Down
13 changes: 10 additions & 3 deletions packages/core/src/models/base-gen2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,19 @@ import {
* Base class for generation 2 hardware (starting with the xl)
*/
export abstract class StreamDeckGen2Base extends StreamDeckBase {
private encodeJPEG: EncodeJPEGHelper
protected readonly encodeJPEG: EncodeJPEGHelper
protected readonly xyFlip: boolean

constructor(device: HIDDevice, options: Required<OpenStreamDeckOptions>, properties: StreamDeckProperties) {
constructor(
device: HIDDevice,
options: Required<OpenStreamDeckOptions>,
properties: StreamDeckProperties,
disableXYFlip?: boolean
) {
super(device, options, properties)

this.encodeJPEG = options.encodeJPEG
this.xyFlip = !disableXYFlip
}

/**
Expand Down Expand Up @@ -91,7 +98,7 @@ export abstract class StreamDeckGen2Base extends StreamDeckBase {
const byteBuffer = imageToByteArray(
sourceBuffer,
sourceOptions,
{ colorMode: 'rgba', xFlip: true, yFlip: true },
{ colorMode: 'rgba', xFlip: this.xyFlip, yFlip: this.xyFlip },
0,
this.ICON_SIZE
)
Expand Down
75 changes: 56 additions & 19 deletions packages/core/src/models/base.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import * as EventEmitter from 'eventemitter3'

import { HIDDevice } from '../device'
import { DeviceModelId } from '../models'
import { KeyIndex } from './id'
import { FillImageOptions, FillPanelOptions, StreamDeck, StreamDeckEvents } from './types'
import { DeviceModelId, EncoderIndex, KeyIndex } from '../id'
import {
FillImageOptions,
FillLcdImageOptions,
FillPanelOptions,
LcdSegmentSize,
StreamDeck,
StreamDeckEvents,
} from '../types'

export type EncodeJPEGHelper = (buffer: Buffer, width: number, height: number) => Promise<Buffer>

Expand Down Expand Up @@ -38,6 +44,19 @@ export abstract class StreamDeckInputBase extends EventEmitter<StreamDeckEvents>
return this.deviceProperties.ROWS
}

get NUM_ENCODERS(): number {
// Overridden by models which support this
return 0
}
get LCD_STRIP_SIZE(): LcdSegmentSize | undefined {
// Overridden by models which support this
return undefined
}
public get LCD_ENCODER_SIZE(): LcdSegmentSize | undefined {
// Overridden by models which support this
return undefined
}

get ICON_SIZE(): number {
return this.deviceProperties.ICON_SIZE
}
Expand Down Expand Up @@ -68,28 +87,30 @@ export abstract class StreamDeckInputBase extends EventEmitter<StreamDeckEvents>

this.keyState = new Array<boolean>(this.NUM_KEYS).fill(false)

this.device.dataKeyOffset = properties.KEY_DATA_OFFSET
this.device.on('input', (data) => {
for (let i = 0; i < this.NUM_KEYS; i++) {
const keyPressed = Boolean(data[i])
const keyIndex = this.transformKeyIndex(i)
const stateChanged = keyPressed !== this.keyState[keyIndex]
if (stateChanged) {
this.keyState[keyIndex] = keyPressed
if (keyPressed) {
this.emit('down', keyIndex)
} else {
this.emit('up', keyIndex)
}
}
}
})
this.device.on('input', (data: Uint8Array) => this.handleInputBuffer(data))

this.device.on('error', (err) => {
this.emit('error', err)
})
}

protected handleInputBuffer(data: Uint8Array): void {
const keyData = data.subarray(this.deviceProperties.KEY_DATA_OFFSET || 0)
for (let i = 0; i < this.NUM_KEYS; i++) {
const keyPressed = Boolean(keyData[i])
const keyIndex = this.transformKeyIndex(i)
const stateChanged = keyPressed !== this.keyState[keyIndex]
if (stateChanged) {
this.keyState[keyIndex] = keyPressed
if (keyPressed) {
this.emit('down', keyIndex)
} else {
this.emit('up', keyIndex)
}
}
}
}

public checkValidKeyIndex(keyIndex: KeyIndex): void {
if (keyIndex < 0 || keyIndex >= this.NUM_KEYS) {
throw new TypeError(`Expected a valid keyIndex 0 - ${this.NUM_KEYS - 1}`)
Expand All @@ -115,6 +136,22 @@ export abstract class StreamDeckInputBase extends EventEmitter<StreamDeckEvents>
public abstract fillKeyBuffer(keyIndex: KeyIndex, imageBuffer: Buffer, options?: FillImageOptions): Promise<void>
public abstract fillPanelBuffer(imageBuffer: Buffer, options?: FillPanelOptions): Promise<void>

public async fillEncoderLcd(
_index: EncoderIndex,
_buffer: Buffer,
_sourceOptions: FillImageOptions
): Promise<void> {
throw new Error('Not supported for this model')
}
public async fillLcdRegion(
_x: number,
_y: number,
_imageBuffer: Buffer,
_sourceOptions: FillLcdImageOptions
): Promise<void> {
throw new Error('Not supported for this model')
}

public abstract clearKey(keyIndex: KeyIndex): Promise<void>
public abstract clearPanel(): Promise<void>
}
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/models/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
export * from './id'
export { StreamDeck, FillImageOptions, FillPanelOptions } from './types'
export { OpenStreamDeckOptions } from './base'
export { StreamDeckOriginal } from './original'
export { StreamDeckMini } from './mini'
Expand All @@ -9,3 +7,4 @@ export { StreamDeckXLV2 } from './xlv2'
export { StreamDeckOriginalV2 } from './originalv2'
export { StreamDeckOriginalMK2 } from './original-mk2'
export { StreamDeckPedal } from './pedal'
export { StreamDeckPlus } from './plus'
4 changes: 2 additions & 2 deletions packages/core/src/models/mini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HIDDevice } from '../device'
import { BMP_HEADER_LENGTH, imageToByteArray, writeBMPHeader } from '../util'
import { InternalFillImageOptions, OpenStreamDeckOptions, StreamDeckProperties } from './base'
import { StreamDeckGen1Base } from './base-gen1'
import { DeviceModelId } from './id'
import { DeviceModelId } from '../id'

const miniProperties: StreamDeckProperties = {
MODEL: DeviceModelId.MINI,
Expand All @@ -11,7 +11,7 @@ const miniProperties: StreamDeckProperties = {
ROWS: 2,
ICON_SIZE: 80,
KEY_DIRECTION: 'ltr',
KEY_DATA_OFFSET: 1,
KEY_DATA_OFFSET: 0,
}

export class StreamDeckMini extends StreamDeckGen1Base {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/models/miniv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HIDDevice } from '../device'
import { BMP_HEADER_LENGTH, imageToByteArray, writeBMPHeader } from '../util'
import { InternalFillImageOptions, OpenStreamDeckOptions, StreamDeckProperties } from './base'
import { StreamDeckGen1Base } from './base-gen1'
import { DeviceModelId } from './id'
import { DeviceModelId } from '../id'

const miniV2Properties: StreamDeckProperties = {
MODEL: DeviceModelId.MINIV2,
Expand All @@ -11,7 +11,7 @@ const miniV2Properties: StreamDeckProperties = {
ROWS: 2,
ICON_SIZE: 80,
KEY_DIRECTION: 'ltr',
KEY_DATA_OFFSET: 1,
KEY_DATA_OFFSET: 0,
}

export class StreamDeckMiniV2 extends StreamDeckGen1Base {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/models/original-mk2.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { HIDDevice } from '../device'
import { OpenStreamDeckOptions, StreamDeckProperties } from './base'
import { StreamDeckGen2Base } from './base-gen2'
import { DeviceModelId } from './id'
import { DeviceModelId } from '../id'

const origMK2Properties: StreamDeckProperties = {
MODEL: DeviceModelId.ORIGINALMK2,
Expand All @@ -10,7 +10,7 @@ const origMK2Properties: StreamDeckProperties = {
ROWS: 3,
ICON_SIZE: 72,
KEY_DIRECTION: 'ltr',
KEY_DATA_OFFSET: 4,
KEY_DATA_OFFSET: 3,
}

export class StreamDeckOriginalMK2 extends StreamDeckGen2Base {
Expand Down
Loading

0 comments on commit 37479d8

Please sign in to comment.