From be331fc7592eaa25937a9a9b9e922ad12c4c439a Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 10 Sep 2024 22:51:11 +0100 Subject: [PATCH 1/7] feat: prepared buffers (buttons only) --- packages/core/src/index.ts | 1 + packages/core/src/models/base.ts | 26 ++++++ packages/core/src/preparedBuffer.ts | 55 +++++++++++++ packages/core/src/proxy.ts | 20 +++++ .../src/services/buttonsLcdDisplay/default.ts | 82 +++++++++++++++++-- .../src/services/buttonsLcdDisplay/fake.ts | 26 +++++- .../services/buttonsLcdDisplay/interface.ts | 15 ++++ .../src/services/imagePacker/interface.ts | 2 +- packages/core/src/types.ts | 43 ++++++++++ .../examples/fill-panel-when-pressed-sharp.js | 9 +- 10 files changed, 265 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/preparedBuffer.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3f1d564..936aeb7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,6 +16,7 @@ import { NetworkDockFactory } from './models/network-dock.js' export * from './types.js' export * from './id.js' export * from './controlDefinition.js' +export type { PreparedBuffer } from './preparedBuffer.js' export type { HIDDevice, HIDDeviceInfo, HIDDeviceEvents, ChildHIDDeviceInfo } from './hid-device.js' export type { OpenStreamDeckOptions } from './models/base.js' export { StreamDeckProxy } from './proxy.js' diff --git a/packages/core/src/models/base.ts b/packages/core/src/models/base.ts index cb4dfdd..a04f951 100644 --- a/packages/core/src/models/base.ts +++ b/packages/core/src/models/base.ts @@ -17,6 +17,7 @@ import type { CallbackHook } from '../services/callback-hook.js' import type { StreamDeckInputService } from '../services/input/interface.js' import { DEVICE_MODELS, VENDOR_ID } from '../index.js' import type { EncoderLedService } from '../services/encoderLed.js' +import type { PreparedBuffer } from '../preparedBuffer.js' export type EncodeJPEGHelper = (buffer: Uint8Array, width: number, height: number) => Promise @@ -178,10 +179,35 @@ export class StreamDeckBase extends EventEmitter implements St await this.#buttonsLcdService.fillKeyBuffer(keyIndex, imageBuffer, options) } + public async prepareFillKeyBuffer( + keyIndex: KeyIndex, + imageBuffer: Uint8Array | Uint8ClampedArray, + options?: FillImageOptions, + jsonSafe?: boolean, + ): Promise { + return this.#buttonsLcdService.prepareFillKeyBuffer(keyIndex, imageBuffer, options, jsonSafe) + } + + public async sendPreparedFillKeyBuffer(buffer: PreparedBuffer): Promise { + await this.#buttonsLcdService.sendPreparedFillKeyBuffer(buffer) + } + public async fillPanelBuffer(imageBuffer: Uint8Array, options?: FillPanelOptions): Promise { await this.#buttonsLcdService.fillPanelBuffer(imageBuffer, options) } + public async prepareFillPanelBuffer( + imageBuffer: Uint8Array | Uint8ClampedArray, + options?: FillPanelOptions, + jsonSafe?: boolean, + ): Promise { + return this.#buttonsLcdService.prepareFillPanelBuffer(imageBuffer, options, jsonSafe) + } + + public async sendPreparedFillPanelBuffer(buffer: PreparedBuffer): Promise { + await this.#buttonsLcdService.sendPreparedFillPanelBuffer(buffer) + } + public async clearKey(keyIndex: KeyIndex): Promise { this.checkValidKeyIndex(keyIndex, null) diff --git a/packages/core/src/preparedBuffer.ts b/packages/core/src/preparedBuffer.ts new file mode 100644 index 0000000..4574440 --- /dev/null +++ b/packages/core/src/preparedBuffer.ts @@ -0,0 +1,55 @@ +import type { DeviceModelId } from './id' + +export interface PreparedBuffer { + readonly __internal__: never +} + +interface PreparedButtonDrawInternal { + if_you_change_this_you_will_break_everything: string + modelId: DeviceModelId + type: string + do_not_touch: Uint8Array[] | string[] +} + +export function wrapBufferToPreparedBuffer( + modelId: DeviceModelId, + type: string, + buffers: Uint8Array[], + jsonSafe: boolean, +): PreparedBuffer { + let encodedBuffers: PreparedButtonDrawInternal['do_not_touch'] = buffers + + if (jsonSafe) { + const decoder = new TextDecoder() + encodedBuffers = buffers.map((b) => decoder.decode(b)) + } + + return { + if_you_change_this_you_will_break_everything: + 'This is a encoded form of the buffer, exactly as the Stream Deck expects it. Do not touch this object, or you can crash your stream deck', + modelId, + type, + do_not_touch: encodedBuffers, + } satisfies PreparedButtonDrawInternal as any +} + +export function unwrapPreparedBufferToBuffer( + modelId: DeviceModelId, + type: string, + prepared: PreparedBuffer, +): Uint8Array[] { + const preparedInternal = prepared as any as PreparedButtonDrawInternal + if (preparedInternal.modelId !== modelId) throw new Error('Prepared buffer is for a different model!') + + if (preparedInternal.type !== type) throw new Error('Prepared buffer is for a different type!') + + return preparedInternal.do_not_touch.map((b) => { + if (typeof b === 'string') { + return new TextEncoder().encode(b) + } else if (b instanceof Uint8Array) { + return b + } else { + throw new Error('Prepared buffer is not a string or Uint8Array!') + } + }) +} diff --git a/packages/core/src/proxy.ts b/packages/core/src/proxy.ts index 1f49a3d..d88bbed 100644 --- a/packages/core/src/proxy.ts +++ b/packages/core/src/proxy.ts @@ -56,11 +56,31 @@ export class StreamDeckProxy implements StreamDeck { ): ReturnType { return this.device.fillKeyBuffer(...args) } + public async prepareFillKeyBuffer( + ...args: Parameters + ): ReturnType { + return this.device.prepareFillKeyBuffer(...args) + } + public async sendPreparedFillKeyBuffer( + ...args: Parameters + ): ReturnType { + return this.device.sendPreparedFillKeyBuffer(...args) + } public async fillPanelBuffer( ...args: Parameters ): ReturnType { return this.device.fillPanelBuffer(...args) } + public async prepareFillPanelBuffer( + ...args: Parameters + ): ReturnType { + return this.device.prepareFillPanelBuffer(...args) + } + public async sendPreparedFillPanelBuffer( + ...args: Parameters + ): ReturnType { + return this.device.sendPreparedFillPanelBuffer(...args) + } public async clearKey(...args: Parameters): ReturnType { return this.device.clearKey(...args) } diff --git a/packages/core/src/services/buttonsLcdDisplay/default.ts b/packages/core/src/services/buttonsLcdDisplay/default.ts index 177ec56..3222770 100644 --- a/packages/core/src/services/buttonsLcdDisplay/default.ts +++ b/packages/core/src/services/buttonsLcdDisplay/default.ts @@ -9,6 +9,7 @@ import type { FillPanelDimensionsOptions, FillImageOptions, FillPanelOptions } f import type { StreamdeckImageWriter } from '../imageWriter/types.js' import type { ButtonsLcdDisplayService, GridSpan } from './interface.js' import type { ButtonLcdImagePacker, InternalFillImageOptions } from '../imagePacker/interface.js' +import { unwrapPreparedBufferToBuffer, wrapBufferToPreparedBuffer, type PreparedBuffer } from '../../preparedBuffer.js' export class DefaultButtonsLcdService implements ButtonsLcdDisplayService { readonly #imageWriter: StreamdeckImageWriter @@ -101,6 +102,7 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService { ps.push(this.sendKeyRgb(control.hidIndex, 0, 0, 0)) } else { const pixels = new Uint8Array(control.pixelSize.width * control.pixelSize.height * 3) + // TODO - caching? ps.push( this.fillImageRangeControl(control, pixels, { format: 'rgb', @@ -132,6 +134,7 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService { await this.sendKeyRgb(keyIndex, 0, 0, 0) } else { const pixels = new Uint8Array(control.pixelSize.width * control.pixelSize.height * 3) + // TODO - caching? await this.fillImageRangeControl(control, pixels, { format: 'rgb', offset: 0, @@ -180,7 +183,20 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService { } } - public async fillKeyBuffer(keyIndex: KeyIndex, imageBuffer: Uint8Array, options?: FillImageOptions): Promise { + public async fillKeyBuffer( + keyIndex: KeyIndex, + imageBuffer: Uint8Array | Uint8ClampedArray, + options?: FillImageOptions, + ): Promise { + const packets = await this.prepareFillKeyBufferInner(keyIndex, imageBuffer, options) + await this.#device.sendReports(packets) + } + + private async prepareFillKeyBufferInner( + keyIndex: KeyIndex, + imageBuffer: Uint8Array | Uint8ClampedArray, + options: FillImageOptions | undefined, + ): Promise { const sourceFormat = options?.format ?? 'rgb' this.checkSourceFormat(sourceFormat) @@ -198,14 +214,39 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService { throw new RangeError(`Expected image buffer of length ${imageSize}, got length ${imageBuffer.length}`) } - await this.fillImageRangeControl(control, imageBuffer, { + return this.prepareFillImageRangeControl(control, imageBuffer, { format: sourceFormat, offset: 0, stride: control.pixelSize.width * sourceFormat.length, }) } - public async fillPanelBuffer(imageBuffer: Uint8Array, options?: FillPanelOptions): Promise { + public async prepareFillKeyBuffer( + keyIndex: KeyIndex, + imageBuffer: Uint8Array | Uint8ClampedArray, + options: FillImageOptions | undefined, + jsonSafe: boolean | undefined, + ): Promise { + const packets = await this.prepareFillKeyBufferInner(keyIndex, imageBuffer, options) + return wrapBufferToPreparedBuffer(this.#deviceProperties.MODEL, 'fill-key', packets, jsonSafe ?? false) + } + public async sendPreparedFillKeyBuffer(buffer: PreparedBuffer): Promise { + const packets = unwrapPreparedBufferToBuffer(this.#deviceProperties.MODEL, 'fill-key', buffer) + await this.#device.sendReports(packets) + } + + public async fillPanelBuffer( + imageBuffer: Uint8Array | Uint8ClampedArray, + options?: FillPanelOptions, + ): Promise { + const packets = await this.prepareFillPanelBufferInner(imageBuffer, options) + await this.#device.sendReports(packets) + } + + private async prepareFillPanelBufferInner( + imageBuffer: Uint8Array | Uint8ClampedArray, + options?: FillPanelOptions, + ): Promise { const sourceFormat = options?.format ?? 'rgb' this.checkSourceFormat(sourceFormat) @@ -231,7 +272,7 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService { const stride = panelDimensions.width * sourceFormat.length - const ps: Array> = [] + const ps: Array> = [] for (const control of buttonLcdControls) { const controlRow = control.row - panelGridSpan.minRow const controlCol = control.column - panelGridSpan.minCol @@ -244,14 +285,29 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService { // TODO: Implement padding ps.push( - this.fillImageRangeControl(control, imageBuffer, { + this.prepareFillImageRangeControl(control, imageBuffer, { format: sourceFormat, offset: rowOffset + colOffset, stride, }), ) } - await Promise.all(ps) + + const packets = await Promise.all(ps) + return packets.flat() + } + + public async prepareFillPanelBuffer( + imageBuffer: Uint8Array | Uint8ClampedArray, + options: FillPanelOptions | undefined, + jsonSafe: boolean | undefined, + ): Promise { + const packets = await this.prepareFillPanelBufferInner(imageBuffer, options) + return wrapBufferToPreparedBuffer(this.#deviceProperties.MODEL, 'fill-panel', packets, jsonSafe ?? false) + } + public async sendPreparedFillPanelBuffer(buffer: PreparedBuffer): Promise { + const packets = unwrapPreparedBufferToBuffer(this.#deviceProperties.MODEL, 'fill-panel', buffer) + await this.#device.sendReports(packets) } private async sendKeyRgb(keyIndex: number, red: number, green: number, blue: number): Promise { @@ -260,9 +316,18 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService { private async fillImageRangeControl( buttonControl: StreamDeckButtonControlDefinitionLcdFeedback, - imageBuffer: Uint8Array, + imageBuffer: Uint8Array | Uint8ClampedArray, sourceOptions: InternalFillImageOptions, ) { + const packets = await this.prepareFillImageRangeControl(buttonControl, imageBuffer, sourceOptions) + await this.#device.sendReports(packets) + } + + private async prepareFillImageRangeControl( + buttonControl: StreamDeckButtonControlDefinitionLcdFeedback, + imageBuffer: Uint8Array | Uint8ClampedArray, + sourceOptions: InternalFillImageOptions, + ): Promise { if (buttonControl.feedbackType !== 'lcd') throw new TypeError(`keyIndex ${buttonControl.index} does not support lcd feedback`) @@ -272,8 +337,7 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService { buttonControl.pixelSize, ) - const packets = this.#imageWriter.generateFillImageWrites({ keyIndex: buttonControl.hidIndex }, byteBuffer) - await this.#device.sendReports(packets) + return this.#imageWriter.generateFillImageWrites({ keyIndex: buttonControl.hidIndex }, byteBuffer) } private checkRGBValue(value: number): void { diff --git a/packages/core/src/services/buttonsLcdDisplay/fake.ts b/packages/core/src/services/buttonsLcdDisplay/fake.ts index 4f1525a..19253b4 100644 --- a/packages/core/src/services/buttonsLcdDisplay/fake.ts +++ b/packages/core/src/services/buttonsLcdDisplay/fake.ts @@ -1,6 +1,7 @@ -import type { Dimension } from '../../id.js' +import type { Dimension, KeyIndex } from '../../id.js' import type { ButtonsLcdDisplayService } from './interface.js' import type { FillPanelDimensionsOptions, FillImageOptions, FillPanelOptions } from '../../types.js' +import type { PreparedBuffer } from '../../preparedBuffer.js' export class FakeLcdService implements ButtonsLcdDisplayService { public calculateFillPanelDimensions(_options?: FillPanelDimensionsOptions): Dimension | null { @@ -23,7 +24,30 @@ export class FakeLcdService implements ButtonsLcdDisplayService { ): Promise { // Not supported } + public async prepareFillKeyBuffer( + _keyIndex: KeyIndex, + _imageBuffer: Uint8Array | Uint8ClampedArray, + _options: FillImageOptions | undefined, + _jsonSafe: boolean | undefined, + ): Promise { + // Not supported + throw new Error('Not supported') + } + public async sendPreparedFillKeyBuffer(_buffer: PreparedBuffer): Promise { + // Not supported + } public async fillPanelBuffer(_imageBuffer: Uint8Array, _options?: FillPanelOptions): Promise { // Not supported } + public async prepareFillPanelBuffer( + _imageBuffer: Uint8Array | Uint8ClampedArray, + _options: FillPanelOptions | undefined, + _jsonSafe: boolean | undefined, + ): Promise { + // Not supported + throw new Error('Not supported') + } + public async sendPreparedFillPanelBuffer(_buffer: PreparedBuffer): Promise { + // Not supported + } } diff --git a/packages/core/src/services/buttonsLcdDisplay/interface.ts b/packages/core/src/services/buttonsLcdDisplay/interface.ts index e45cb75..7a73871 100644 --- a/packages/core/src/services/buttonsLcdDisplay/interface.ts +++ b/packages/core/src/services/buttonsLcdDisplay/interface.ts @@ -1,5 +1,6 @@ import type { Dimension, KeyIndex } from '../../id.js' import type { FillImageOptions, FillPanelDimensionsOptions, FillPanelOptions } from '../../types.js' +import type { PreparedBuffer } from '../../preparedBuffer.js' export interface GridSpan { minRow: number @@ -16,5 +17,19 @@ export interface ButtonsLcdDisplayService { fillKeyColor(keyIndex: KeyIndex, r: number, g: number, b: number): Promise fillKeyBuffer(keyIndex: KeyIndex, imageBuffer: Uint8Array, options?: FillImageOptions): Promise + prepareFillKeyBuffer( + keyIndex: KeyIndex, + imageBuffer: Uint8Array | Uint8ClampedArray, + options: FillImageOptions | undefined, + jsonSafe: boolean | undefined, + ): Promise + sendPreparedFillKeyBuffer(buffer: PreparedBuffer): Promise + fillPanelBuffer(imageBuffer: Uint8Array, options: FillPanelOptions | undefined): Promise + prepareFillPanelBuffer( + imageBuffer: Uint8Array | Uint8ClampedArray, + options: FillPanelOptions | undefined, + jsonSafe: boolean | undefined, + ): Promise + sendPreparedFillPanelBuffer(buffer: PreparedBuffer): Promise } diff --git a/packages/core/src/services/imagePacker/interface.ts b/packages/core/src/services/imagePacker/interface.ts index b206728..b10092a 100644 --- a/packages/core/src/services/imagePacker/interface.ts +++ b/packages/core/src/services/imagePacker/interface.ts @@ -8,7 +8,7 @@ export interface InternalFillImageOptions extends FillImageOptions { export interface ButtonLcdImagePacker { convertPixelBuffer( - sourceBuffer: Uint8Array, + sourceBuffer: Uint8Array | Uint8ClampedArray, sourceOptions: InternalFillImageOptions, targetSize: Dimension, ): Promise diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 32fa1ec..199273a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -7,6 +7,7 @@ import type { StreamDeckEncoderControlDefinition, StreamDeckLcdSegmentControlDefinition, } from './controlDefinition.js' +import { PreparedBuffer } from './preparedBuffer.js' export interface StreamDeckTcpChildDeviceInfo extends HIDDeviceInfo { readonly model: DeviceModelId @@ -104,6 +105,29 @@ export interface StreamDeck extends EventEmitter { options?: FillImageOptions, ): Promise + /** + * Prepare to fill the given key with an image in a Buffer. + * Note: The result is only guaranteed to be valid for this specific StreamDeck and the same library version, but is safe to store externally. + * + * @param {number} keyIndex The key to fill + * @param {Buffer} imageBuffer The image to write + * @param {Object} options Options to control the write + * @param {boolean} jsonSafe Whether the result should be packed to be safe to json serialize + */ + prepareFillKeyBuffer( + keyIndex: KeyIndex, + imageBuffer: Uint8Array | Uint8ClampedArray, + options?: FillImageOptions, + jsonSafe?: boolean, + ): Promise + + /** + * Send a prepared fill key operation + * + * @param {PreparedBuffer} buffer The prepared buffer to draw + */ + sendPreparedFillKeyBuffer(buffer: PreparedBuffer): Promise + /** * Fills the whole panel with an image in a Buffer. * @@ -112,6 +136,25 @@ export interface StreamDeck extends EventEmitter { */ fillPanelBuffer(imageBuffer: Uint8Array | Uint8ClampedArray, options?: FillPanelOptions): Promise + /** + * Prepare to fill the whole panel with an image in a Buffer. + * Note: The result is only guaranteed to be valid for this specific StreamDeck and the same library version, but is safe to store externally. + * + * @param {Buffer} imageBuffer The image to write + * @param {Object} options Options to control the write + */ + prepareFillPanelBuffer( + imageBuffer: Uint8Array | Uint8ClampedArray, + options?: FillPanelOptions, + ): Promise + + /** + * Send a prepared fill panel operation + * + * @param {PreparedBuffer} buffer The prepared buffer to draw + */ + sendPreparedFillPanelBuffer(buffer: PreparedBuffer): Promise + /** * Fill the whole lcd segment * @param {number} lcdIndex The id of the lcd segment to draw to diff --git a/packages/node/examples/fill-panel-when-pressed-sharp.js b/packages/node/examples/fill-panel-when-pressed-sharp.js index adee1c6..4beb028 100644 --- a/packages/node/examples/fill-panel-when-pressed-sharp.js +++ b/packages/node/examples/fill-panel-when-pressed-sharp.js @@ -38,6 +38,9 @@ console.log('Press keys 0-7 to show the first image, and keys 8-15 to show the s .raw() .toBuffer() + const preparedField = await streamDeck.prepareFillPanelBuffer(imgField) + const preparedMosaic = await streamDeck.prepareFillPanelBuffer(imgMosaic) + const imgFieldLcd = lcdSegmentControl ? await sharp(path.resolve(__dirname, 'fixtures/sunny_field.png')) .flatten() @@ -67,17 +70,17 @@ console.log('Press keys 0-7 to show the first image, and keys 8-15 to show the s let color if (control.index > buttonCount / 2) { console.log('Filling entire panel with an image of a sunny field.') - image = imgField + image = preparedField imageLcd = imgFieldLcd color = [0, 255, 0] } else { console.log('Filling entire panel with a mosaic which will show each key as a different color.') - image = imgMosaic + image = preparedMosaic imageLcd = imgMosaicLcd color = [255, 0, 255] } - streamDeck.fillPanelBuffer(image).catch((e) => console.error('Fill failed:', e)) + streamDeck.sendPreparedFillPanelBuffer(image).catch((e) => console.error('Fill failed:', e)) if (imageLcd) { streamDeck .fillLcd(lcdSegmentControl.id, imageLcd, { format: 'rgb' }) From 6952eea6a738bb9c0c7891d463e52a8b73af897f Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 10 Sep 2024 22:52:50 +0100 Subject: [PATCH 2/7] wip: export types --- packages/node/src/index.ts | 3 +++ packages/tcp/src/index.ts | 6 ++++++ packages/webhid/src/index.ts | 4 ++++ 3 files changed, 13 insertions(+) diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index e294167..1d4e73d 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -10,6 +10,7 @@ export { DeviceModelId, KeyIndex, StreamDeck, + StreamDeckProxy, LcdPosition, Dimension, StreamDeckControlDefinitionBase, @@ -20,7 +21,9 @@ export { StreamDeckEncoderControlDefinition, StreamDeckLcdSegmentControlDefinition, StreamDeckControlDefinition, + StreamDeckTcpChildDeviceInfo, OpenStreamDeckOptions, + PreparedBuffer, } from '@elgato-stream-deck/core' export { StreamDeckDeviceInfo, JPEGEncodeOptions } diff --git a/packages/tcp/src/index.ts b/packages/tcp/src/index.ts index 8b09a2a..88794f7 100644 --- a/packages/tcp/src/index.ts +++ b/packages/tcp/src/index.ts @@ -5,14 +5,20 @@ export { DeviceModelId, KeyIndex, StreamDeck, + StreamDeckProxy, LcdPosition, Dimension, StreamDeckControlDefinitionBase, StreamDeckButtonControlDefinition, + StreamDeckButtonControlDefinitionNoFeedback, + StreamDeckButtonControlDefinitionRgbFeedback, + StreamDeckButtonControlDefinitionLcdFeedback, StreamDeckEncoderControlDefinition, StreamDeckLcdSegmentControlDefinition, StreamDeckControlDefinition, + StreamDeckTcpChildDeviceInfo, OpenStreamDeckOptions, + PreparedBuffer, } from '@elgato-stream-deck/core' export * from './types.js' diff --git a/packages/webhid/src/index.ts b/packages/webhid/src/index.ts index 59cc101..5f32aa7 100644 --- a/packages/webhid/src/index.ts +++ b/packages/webhid/src/index.ts @@ -11,6 +11,7 @@ export { DeviceModelId, KeyIndex, StreamDeck, + StreamDeckProxy, LcdPosition, Dimension, StreamDeckControlDefinitionBase, @@ -21,8 +22,11 @@ export { StreamDeckEncoderControlDefinition, StreamDeckLcdSegmentControlDefinition, StreamDeckControlDefinition, + StreamDeckTcpChildDeviceInfo, OpenStreamDeckOptions, + PreparedBuffer, } from '@elgato-stream-deck/core' + export { StreamDeckWeb } from './wrapper.js' /** From dc7487d0395e89583bc06b58c2eea54713fc0bd7 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sun, 16 Feb 2025 17:14:44 +0000 Subject: [PATCH 3/7] chore: docs --- packages/core/src/preparedBuffer.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/core/src/preparedBuffer.ts b/packages/core/src/preparedBuffer.ts index 4574440..b78695f 100644 --- a/packages/core/src/preparedBuffer.ts +++ b/packages/core/src/preparedBuffer.ts @@ -1,5 +1,13 @@ -import type { DeviceModelId } from './id' - +import type { DeviceModelId } from './id.js' + +/** + * This represents a buffer that has been prepared for sending to a Stream Deck. + * Note: The result is only guaranteed to be valid for this specific StreamDeck and the same library version, but is safe to store externally. + * + * This is an opaque type, and should not be viewed/inspected directly. + * + * It may be serialized to JSON, but only if it was generated with the `jsonSafe` flag set to `true`. + */ export interface PreparedBuffer { readonly __internal__: never } From 9c4a335f5986e351a3863943c66f15607093727c Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sun, 16 Feb 2025 17:15:56 +0000 Subject: [PATCH 4/7] chore: lint --- packages/core/src/types.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 199273a..96e7691 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -7,7 +7,7 @@ import type { StreamDeckEncoderControlDefinition, StreamDeckLcdSegmentControlDefinition, } from './controlDefinition.js' -import { PreparedBuffer } from './preparedBuffer.js' +import type { PreparedBuffer } from './preparedBuffer.js' export interface StreamDeckTcpChildDeviceInfo extends HIDDeviceInfo { readonly model: DeviceModelId @@ -255,5 +255,9 @@ export interface StreamDeck extends EventEmitter { */ getSerialNumber(): Promise + /** + * Get information about a child device connected to this Stream Deck, if any is connected + * Note: This is only available for the Stream Deck Studio, but is safe to call for other models + */ getChildDeviceInfo(): Promise } From a1c6fb8753be56a9026f0ba8298f9f032ea5f261 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sun, 16 Feb 2025 17:28:13 +0000 Subject: [PATCH 5/7] wip: fill lcd region --- packages/core/src/models/base.ts | 16 ++++++++ packages/core/src/proxy.ts | 12 ++++++ .../services/lcdSegmentDisplay/interface.ts | 27 +++++++++++++ .../src/services/lcdSegmentDisplay/neo.ts | 16 ++++++++ .../src/services/lcdSegmentDisplay/plus.ts | 38 +++++++++++++++---- packages/core/src/types.ts | 27 +++++++++++++ 6 files changed, 129 insertions(+), 7 deletions(-) diff --git a/packages/core/src/models/base.ts b/packages/core/src/models/base.ts index a04f951..f6a7e31 100644 --- a/packages/core/src/models/base.ts +++ b/packages/core/src/models/base.ts @@ -238,6 +238,22 @@ export class StreamDeckBase extends EventEmitter implements St return this.#lcdSegmentDisplayService.fillLcdRegion(...args) } + public async prepareFillLcdRegion( + ...args: Parameters + ): ReturnType { + if (!this.#lcdSegmentDisplayService) throw new Error('Not supported for this model') + + return this.#lcdSegmentDisplayService.prepareFillLcdRegion(...args) + } + + public async sendPreparedFillLcdRegion( + ...args: Parameters + ): ReturnType { + if (!this.#lcdSegmentDisplayService) throw new Error('Not supported for this model') + + return this.#lcdSegmentDisplayService.sendPreparedFillLcdRegion(...args) + } + public async clearLcdSegment( ...args: Parameters ): ReturnType { diff --git a/packages/core/src/proxy.ts b/packages/core/src/proxy.ts index d88bbed..4de008b 100644 --- a/packages/core/src/proxy.ts +++ b/packages/core/src/proxy.ts @@ -133,6 +133,18 @@ export class StreamDeckProxy implements StreamDeck { return this.device.fillLcdRegion(...args) } + public async prepareFillLcdRegion( + ...args: Parameters + ): ReturnType { + return this.device.prepareFillLcdRegion(...args) + } + + public async sendPreparedFillLcdRegion( + ...args: Parameters + ): ReturnType { + return this.device.sendPreparedFillLcdRegion(...args) + } + public async clearLcdSegment( ...args: Parameters ): ReturnType { diff --git a/packages/core/src/services/lcdSegmentDisplay/interface.ts b/packages/core/src/services/lcdSegmentDisplay/interface.ts index 261652d..038e598 100644 --- a/packages/core/src/services/lcdSegmentDisplay/interface.ts +++ b/packages/core/src/services/lcdSegmentDisplay/interface.ts @@ -1,3 +1,4 @@ +import type { PreparedBuffer } from '../../preparedBuffer.js' import type { FillImageOptions, FillLcdImageOptions } from '../../types.js' export interface LcdSegmentDisplayService { @@ -29,6 +30,32 @@ export interface LcdSegmentDisplayService { sourceOptions: FillLcdImageOptions, ): Promise + /** + * Prepare to fill region of the lcd with an image in a Buffer. + * Note: The result is only guaranteed to be valid for this specific StreamDeck and the same library version, but is safe to store externally. + * + * @param {number} lcdIndex The id of the lcd segment to draw to + * @param {number} x The x position to draw to + * @param {number} y The y position to draw to + * @param {Buffer} imageBuffer The image to write + * @param {Object} sourceOptions Options to control the write + */ + prepareFillLcdRegion( + lcdIndex: number, + x: number, + y: number, + imageBuffer: Uint8Array, + sourceOptions: FillLcdImageOptions, + jsonSafe?: boolean, + ): Promise + + /** + * Send a prepared fill region of the lcd operation + * + * @param {PreparedBuffer} buffer The prepared buffer to draw + */ + sendPreparedFillLcdRegion(buffer: PreparedBuffer): Promise + /** * Clear the lcd segment to black * @param {number} lcdIndex The id of the lcd segment to clear diff --git a/packages/core/src/services/lcdSegmentDisplay/neo.ts b/packages/core/src/services/lcdSegmentDisplay/neo.ts index b18afc2..0564863 100644 --- a/packages/core/src/services/lcdSegmentDisplay/neo.ts +++ b/packages/core/src/services/lcdSegmentDisplay/neo.ts @@ -8,6 +8,7 @@ import type { LcdSegmentDisplayService } from './interface.js' import type { FillLcdImageOptions, FillImageOptions } from '../../types.js' import { transformImageBuffer } from '../../util.js' import type { EncodeJPEGHelper } from '../../models/base.js' +import type { PreparedBuffer } from '../../preparedBuffer.js' export class StreamDeckNeoLcdService implements LcdSegmentDisplayService { readonly #encodeJPEG: EncodeJPEGHelper @@ -36,6 +37,21 @@ export class StreamDeckNeoLcdService implements LcdSegmentDisplayService { throw new Error('Not supported for this model') } + public async prepareFillLcdRegion( + _index: number, + _x: number, + _y: number, + _imageBuffer: Uint8Array, + _sourceOptions: FillLcdImageOptions, + _jsonSafe?: boolean, + ): Promise { + throw new Error('Not supported for this model') + } + + public async sendPreparedFillLcdRegion(_buffer: PreparedBuffer): Promise { + throw new Error('Not supported for this model') + } + public async fillLcd(index: number, imageBuffer: Uint8Array, sourceOptions: FillImageOptions): Promise { const lcdControl = this.#lcdControls.find((control) => control.id === index) if (!lcdControl) throw new Error(`Invalid lcd segment index ${index}`) diff --git a/packages/core/src/services/lcdSegmentDisplay/plus.ts b/packages/core/src/services/lcdSegmentDisplay/plus.ts index b917469..7ca4713 100644 --- a/packages/core/src/services/lcdSegmentDisplay/plus.ts +++ b/packages/core/src/services/lcdSegmentDisplay/plus.ts @@ -7,6 +7,8 @@ import type { LcdSegmentDisplayService } from './interface.js' import type { FillImageOptions, FillLcdImageOptions } from '../../types.js' import { transformImageBuffer } from '../../util.js' import type { EncodeJPEGHelper } from '../../models/base.js' +import { unwrapPreparedBufferToBuffer, wrapBufferToPreparedBuffer, type PreparedBuffer } from '../../preparedBuffer.js' +import { DeviceModelId } from '../../id.js' export class StreamDeckPlusLcdService implements LcdSegmentDisplayService { readonly #encodeJPEG: EncodeJPEGHelper @@ -33,11 +35,12 @@ export class StreamDeckPlusLcdService implements LcdSegmentDisplayService { const lcdControl = this.#lcdControls.find((control) => control.id === index) if (!lcdControl) throw new Error(`Invalid lcd segment index ${index}`) - return this.fillControlRegion(lcdControl, 0, 0, buffer, { + const packets = await this.prepareFillControlRegion(lcdControl, 0, 0, buffer, { format: sourceOptions.format, width: lcdControl.pixelSize.width, height: lcdControl.pixelSize.height, }) + await this.#device.sendReports(packets) } public async fillLcdRegion( @@ -50,7 +53,28 @@ export class StreamDeckPlusLcdService implements LcdSegmentDisplayService { const lcdControl = this.#lcdControls.find((control) => control.id === index) if (!lcdControl) throw new Error(`Invalid lcd segment index ${index}`) - return this.fillControlRegion(lcdControl, x, y, imageBuffer, sourceOptions) + const packets = await this.prepareFillControlRegion(lcdControl, x, y, imageBuffer, sourceOptions) + await this.#device.sendReports(packets) + } + + public async prepareFillLcdRegion( + index: number, + x: number, + y: number, + imageBuffer: Uint8Array, + sourceOptions: FillLcdImageOptions, + jsonSafe?: boolean, + ): Promise { + const lcdControl = this.#lcdControls.find((control) => control.id === index) + if (!lcdControl) throw new Error(`Invalid lcd segment index ${index}`) + + const packets = await this.prepareFillControlRegion(lcdControl, x, y, imageBuffer, sourceOptions) + return wrapBufferToPreparedBuffer(DeviceModelId.PLUS, 'fill-lcd-region', packets, jsonSafe ?? false) + } + + public async sendPreparedFillLcdRegion(buffer: PreparedBuffer): Promise { + const packets = unwrapPreparedBufferToBuffer(DeviceModelId.PLUS, 'fill-lcd-region', buffer) + await this.#device.sendReports(packets) } public async clearLcdSegment(index: number): Promise { @@ -59,11 +83,12 @@ export class StreamDeckPlusLcdService implements LcdSegmentDisplayService { const buffer = new Uint8Array(lcdControl.pixelSize.width * lcdControl.pixelSize.height * 4) - await this.fillControlRegion(lcdControl, 0, 0, buffer, { + const packets = await this.prepareFillControlRegion(lcdControl, 0, 0, buffer, { format: 'rgba', width: lcdControl.pixelSize.width, height: lcdControl.pixelSize.height, }) + await this.#device.sendReports(packets) } public async clearAllLcdSegments(): Promise { @@ -75,13 +100,13 @@ export class StreamDeckPlusLcdService implements LcdSegmentDisplayService { await Promise.all(ps) } - private async fillControlRegion( + private async prepareFillControlRegion( lcdControl: StreamDeckLcdSegmentControlDefinition, x: number, y: number, imageBuffer: Uint8Array | Uint8ClampedArray, sourceOptions: FillLcdImageOptions, - ): Promise { + ): Promise { // Basic bounds checking const maxSize = lcdControl.pixelSize if (x < 0 || x + sourceOptions.width > maxSize.width) { @@ -99,8 +124,7 @@ export class StreamDeckPlusLcdService implements LcdSegmentDisplayService { // A lot of this drawing code is heavily based on the normal button const byteBuffer = await this.convertFillLcdBuffer(imageBuffer, sourceOptions) - const packets = this.#lcdImageWriter.generateFillImageWrites({ ...sourceOptions, x, y }, byteBuffer) - await this.#device.sendReports(packets) + return this.#lcdImageWriter.generateFillImageWrites({ ...sourceOptions, x, y }, byteBuffer) } private async convertFillLcdBuffer( diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 96e7691..a192cf6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -146,6 +146,7 @@ export interface StreamDeck extends EventEmitter { prepareFillPanelBuffer( imageBuffer: Uint8Array | Uint8ClampedArray, options?: FillPanelOptions, + jsonSafe?: boolean, ): Promise /** @@ -208,6 +209,32 @@ export interface StreamDeck extends EventEmitter { sourceOptions: FillLcdImageOptions, ): Promise + /** + * Prepare to fill region of the lcd with an image in a Buffer. + * Note: The result is only guaranteed to be valid for this specific StreamDeck and the same library version, but is safe to store externally. + * + * @param {number} lcdIndex The id of the lcd segment to draw to + * @param {number} x The x position to draw to + * @param {number} y The y position to draw to + * @param {Buffer} imageBuffer The image to write + * @param {Object} sourceOptions Options to control the write + */ + prepareFillLcdRegion( + lcdIndex: number, + x: number, + y: number, + imageBuffer: Uint8Array, + sourceOptions: FillLcdImageOptions, + jsonSafe?: boolean, + ): Promise + + /** + * Send a prepared fill region of the lcd operation + * + * @param {PreparedBuffer} buffer The prepared buffer to draw + */ + sendPreparedFillLcdRegion(buffer: PreparedBuffer): Promise + /** * Clear the lcd segment to black * @param {number} lcdIndex The id of the lcd segment to clear From cf56aebcf07bb941a753987b5e852680f1dc8dce Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sun, 16 Feb 2025 17:32:08 +0000 Subject: [PATCH 6/7] wip: simplify send prepared --- packages/core/src/models/base.ts | 23 ++++----------- packages/core/src/preparedBuffer.ts | 5 ++-- packages/core/src/proxy.ts | 21 ++++---------- .../src/services/buttonsLcdDisplay/default.ts | 10 +------ .../src/services/buttonsLcdDisplay/fake.ts | 6 ---- .../services/buttonsLcdDisplay/interface.ts | 2 -- .../services/lcdSegmentDisplay/interface.ts | 7 ----- .../src/services/lcdSegmentDisplay/neo.ts | 4 --- .../src/services/lcdSegmentDisplay/plus.ts | 7 +---- packages/core/src/types.ts | 28 +++++-------------- .../examples/fill-panel-when-pressed-sharp.js | 2 +- 11 files changed, 24 insertions(+), 91 deletions(-) diff --git a/packages/core/src/models/base.ts b/packages/core/src/models/base.ts index f6a7e31..a42c1f4 100644 --- a/packages/core/src/models/base.ts +++ b/packages/core/src/models/base.ts @@ -17,7 +17,7 @@ import type { CallbackHook } from '../services/callback-hook.js' import type { StreamDeckInputService } from '../services/input/interface.js' import { DEVICE_MODELS, VENDOR_ID } from '../index.js' import type { EncoderLedService } from '../services/encoderLed.js' -import type { PreparedBuffer } from '../preparedBuffer.js' +import { unwrapPreparedBufferToBuffer, type PreparedBuffer } from '../preparedBuffer.js' export type EncodeJPEGHelper = (buffer: Uint8Array, width: number, height: number) => Promise @@ -167,6 +167,11 @@ export class StreamDeckBase extends EventEmitter implements St return this.#propertiesService.getSerialNumber() } + public async sendPreparedBuffer(buffer: PreparedBuffer): Promise { + const packets = unwrapPreparedBufferToBuffer(this.deviceProperties.MODEL, buffer) + await this.device.sendReports(packets) + } + public async fillKeyColor(keyIndex: KeyIndex, r: number, g: number, b: number): Promise { this.checkValidKeyIndex(keyIndex, null) @@ -188,10 +193,6 @@ export class StreamDeckBase extends EventEmitter implements St return this.#buttonsLcdService.prepareFillKeyBuffer(keyIndex, imageBuffer, options, jsonSafe) } - public async sendPreparedFillKeyBuffer(buffer: PreparedBuffer): Promise { - await this.#buttonsLcdService.sendPreparedFillKeyBuffer(buffer) - } - public async fillPanelBuffer(imageBuffer: Uint8Array, options?: FillPanelOptions): Promise { await this.#buttonsLcdService.fillPanelBuffer(imageBuffer, options) } @@ -204,10 +205,6 @@ export class StreamDeckBase extends EventEmitter implements St return this.#buttonsLcdService.prepareFillPanelBuffer(imageBuffer, options, jsonSafe) } - public async sendPreparedFillPanelBuffer(buffer: PreparedBuffer): Promise { - await this.#buttonsLcdService.sendPreparedFillPanelBuffer(buffer) - } - public async clearKey(keyIndex: KeyIndex): Promise { this.checkValidKeyIndex(keyIndex, null) @@ -246,14 +243,6 @@ export class StreamDeckBase extends EventEmitter implements St return this.#lcdSegmentDisplayService.prepareFillLcdRegion(...args) } - public async sendPreparedFillLcdRegion( - ...args: Parameters - ): ReturnType { - if (!this.#lcdSegmentDisplayService) throw new Error('Not supported for this model') - - return this.#lcdSegmentDisplayService.sendPreparedFillLcdRegion(...args) - } - public async clearLcdSegment( ...args: Parameters ): ReturnType { diff --git a/packages/core/src/preparedBuffer.ts b/packages/core/src/preparedBuffer.ts index b78695f..8c04459 100644 --- a/packages/core/src/preparedBuffer.ts +++ b/packages/core/src/preparedBuffer.ts @@ -3,6 +3,7 @@ import type { DeviceModelId } from './id.js' /** * This represents a buffer that has been prepared for sending to a Stream Deck. * Note: The result is only guaranteed to be valid for this specific StreamDeck and the same library version, but is safe to store externally. + * If it sent to the wrong model, the result is undefined behaviour. * * This is an opaque type, and should not be viewed/inspected directly. * @@ -43,13 +44,13 @@ export function wrapBufferToPreparedBuffer( export function unwrapPreparedBufferToBuffer( modelId: DeviceModelId, - type: string, + // type: string, prepared: PreparedBuffer, ): Uint8Array[] { const preparedInternal = prepared as any as PreparedButtonDrawInternal if (preparedInternal.modelId !== modelId) throw new Error('Prepared buffer is for a different model!') - if (preparedInternal.type !== type) throw new Error('Prepared buffer is for a different type!') + // if (preparedInternal.type !== type) throw new Error('Prepared buffer is for a different type!') return preparedInternal.do_not_touch.map((b) => { if (typeof b === 'string') { diff --git a/packages/core/src/proxy.ts b/packages/core/src/proxy.ts index 4de008b..9bc05dd 100644 --- a/packages/core/src/proxy.ts +++ b/packages/core/src/proxy.ts @@ -48,6 +48,11 @@ export class StreamDeckProxy implements StreamDeck { ): ReturnType { return this.device.getHidDeviceInfo(...args) } + public async sendPreparedBuffer( + ...args: Parameters + ): ReturnType { + return this.device.sendPreparedBuffer(...args) + } public async fillKeyColor(...args: Parameters): ReturnType { return this.device.fillKeyColor(...args) } @@ -61,11 +66,6 @@ export class StreamDeckProxy implements StreamDeck { ): ReturnType { return this.device.prepareFillKeyBuffer(...args) } - public async sendPreparedFillKeyBuffer( - ...args: Parameters - ): ReturnType { - return this.device.sendPreparedFillKeyBuffer(...args) - } public async fillPanelBuffer( ...args: Parameters ): ReturnType { @@ -76,11 +76,6 @@ export class StreamDeckProxy implements StreamDeck { ): ReturnType { return this.device.prepareFillPanelBuffer(...args) } - public async sendPreparedFillPanelBuffer( - ...args: Parameters - ): ReturnType { - return this.device.sendPreparedFillPanelBuffer(...args) - } public async clearKey(...args: Parameters): ReturnType { return this.device.clearKey(...args) } @@ -139,12 +134,6 @@ export class StreamDeckProxy implements StreamDeck { return this.device.prepareFillLcdRegion(...args) } - public async sendPreparedFillLcdRegion( - ...args: Parameters - ): ReturnType { - return this.device.sendPreparedFillLcdRegion(...args) - } - public async clearLcdSegment( ...args: Parameters ): ReturnType { diff --git a/packages/core/src/services/buttonsLcdDisplay/default.ts b/packages/core/src/services/buttonsLcdDisplay/default.ts index 3222770..b771d05 100644 --- a/packages/core/src/services/buttonsLcdDisplay/default.ts +++ b/packages/core/src/services/buttonsLcdDisplay/default.ts @@ -9,7 +9,7 @@ import type { FillPanelDimensionsOptions, FillImageOptions, FillPanelOptions } f import type { StreamdeckImageWriter } from '../imageWriter/types.js' import type { ButtonsLcdDisplayService, GridSpan } from './interface.js' import type { ButtonLcdImagePacker, InternalFillImageOptions } from '../imagePacker/interface.js' -import { unwrapPreparedBufferToBuffer, wrapBufferToPreparedBuffer, type PreparedBuffer } from '../../preparedBuffer.js' +import { wrapBufferToPreparedBuffer, type PreparedBuffer } from '../../preparedBuffer.js' export class DefaultButtonsLcdService implements ButtonsLcdDisplayService { readonly #imageWriter: StreamdeckImageWriter @@ -230,10 +230,6 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService { const packets = await this.prepareFillKeyBufferInner(keyIndex, imageBuffer, options) return wrapBufferToPreparedBuffer(this.#deviceProperties.MODEL, 'fill-key', packets, jsonSafe ?? false) } - public async sendPreparedFillKeyBuffer(buffer: PreparedBuffer): Promise { - const packets = unwrapPreparedBufferToBuffer(this.#deviceProperties.MODEL, 'fill-key', buffer) - await this.#device.sendReports(packets) - } public async fillPanelBuffer( imageBuffer: Uint8Array | Uint8ClampedArray, @@ -305,10 +301,6 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService { const packets = await this.prepareFillPanelBufferInner(imageBuffer, options) return wrapBufferToPreparedBuffer(this.#deviceProperties.MODEL, 'fill-panel', packets, jsonSafe ?? false) } - public async sendPreparedFillPanelBuffer(buffer: PreparedBuffer): Promise { - const packets = unwrapPreparedBufferToBuffer(this.#deviceProperties.MODEL, 'fill-panel', buffer) - await this.#device.sendReports(packets) - } private async sendKeyRgb(keyIndex: number, red: number, green: number, blue: number): Promise { await this.#device.sendFeatureReport(new Uint8Array([0x03, 0x06, keyIndex, red, green, blue])) diff --git a/packages/core/src/services/buttonsLcdDisplay/fake.ts b/packages/core/src/services/buttonsLcdDisplay/fake.ts index 19253b4..e936044 100644 --- a/packages/core/src/services/buttonsLcdDisplay/fake.ts +++ b/packages/core/src/services/buttonsLcdDisplay/fake.ts @@ -33,9 +33,6 @@ export class FakeLcdService implements ButtonsLcdDisplayService { // Not supported throw new Error('Not supported') } - public async sendPreparedFillKeyBuffer(_buffer: PreparedBuffer): Promise { - // Not supported - } public async fillPanelBuffer(_imageBuffer: Uint8Array, _options?: FillPanelOptions): Promise { // Not supported } @@ -47,7 +44,4 @@ export class FakeLcdService implements ButtonsLcdDisplayService { // Not supported throw new Error('Not supported') } - public async sendPreparedFillPanelBuffer(_buffer: PreparedBuffer): Promise { - // Not supported - } } diff --git a/packages/core/src/services/buttonsLcdDisplay/interface.ts b/packages/core/src/services/buttonsLcdDisplay/interface.ts index 7a73871..1418e91 100644 --- a/packages/core/src/services/buttonsLcdDisplay/interface.ts +++ b/packages/core/src/services/buttonsLcdDisplay/interface.ts @@ -23,7 +23,6 @@ export interface ButtonsLcdDisplayService { options: FillImageOptions | undefined, jsonSafe: boolean | undefined, ): Promise - sendPreparedFillKeyBuffer(buffer: PreparedBuffer): Promise fillPanelBuffer(imageBuffer: Uint8Array, options: FillPanelOptions | undefined): Promise prepareFillPanelBuffer( @@ -31,5 +30,4 @@ export interface ButtonsLcdDisplayService { options: FillPanelOptions | undefined, jsonSafe: boolean | undefined, ): Promise - sendPreparedFillPanelBuffer(buffer: PreparedBuffer): Promise } diff --git a/packages/core/src/services/lcdSegmentDisplay/interface.ts b/packages/core/src/services/lcdSegmentDisplay/interface.ts index 038e598..73346e1 100644 --- a/packages/core/src/services/lcdSegmentDisplay/interface.ts +++ b/packages/core/src/services/lcdSegmentDisplay/interface.ts @@ -49,13 +49,6 @@ export interface LcdSegmentDisplayService { jsonSafe?: boolean, ): Promise - /** - * Send a prepared fill region of the lcd operation - * - * @param {PreparedBuffer} buffer The prepared buffer to draw - */ - sendPreparedFillLcdRegion(buffer: PreparedBuffer): Promise - /** * Clear the lcd segment to black * @param {number} lcdIndex The id of the lcd segment to clear diff --git a/packages/core/src/services/lcdSegmentDisplay/neo.ts b/packages/core/src/services/lcdSegmentDisplay/neo.ts index 0564863..327f474 100644 --- a/packages/core/src/services/lcdSegmentDisplay/neo.ts +++ b/packages/core/src/services/lcdSegmentDisplay/neo.ts @@ -48,10 +48,6 @@ export class StreamDeckNeoLcdService implements LcdSegmentDisplayService { throw new Error('Not supported for this model') } - public async sendPreparedFillLcdRegion(_buffer: PreparedBuffer): Promise { - throw new Error('Not supported for this model') - } - public async fillLcd(index: number, imageBuffer: Uint8Array, sourceOptions: FillImageOptions): Promise { const lcdControl = this.#lcdControls.find((control) => control.id === index) if (!lcdControl) throw new Error(`Invalid lcd segment index ${index}`) diff --git a/packages/core/src/services/lcdSegmentDisplay/plus.ts b/packages/core/src/services/lcdSegmentDisplay/plus.ts index 7ca4713..ea3565c 100644 --- a/packages/core/src/services/lcdSegmentDisplay/plus.ts +++ b/packages/core/src/services/lcdSegmentDisplay/plus.ts @@ -7,7 +7,7 @@ import type { LcdSegmentDisplayService } from './interface.js' import type { FillImageOptions, FillLcdImageOptions } from '../../types.js' import { transformImageBuffer } from '../../util.js' import type { EncodeJPEGHelper } from '../../models/base.js' -import { unwrapPreparedBufferToBuffer, wrapBufferToPreparedBuffer, type PreparedBuffer } from '../../preparedBuffer.js' +import { wrapBufferToPreparedBuffer, type PreparedBuffer } from '../../preparedBuffer.js' import { DeviceModelId } from '../../id.js' export class StreamDeckPlusLcdService implements LcdSegmentDisplayService { @@ -72,11 +72,6 @@ export class StreamDeckPlusLcdService implements LcdSegmentDisplayService { return wrapBufferToPreparedBuffer(DeviceModelId.PLUS, 'fill-lcd-region', packets, jsonSafe ?? false) } - public async sendPreparedFillLcdRegion(buffer: PreparedBuffer): Promise { - const packets = unwrapPreparedBufferToBuffer(DeviceModelId.PLUS, 'fill-lcd-region', buffer) - await this.#device.sendReports(packets) - } - public async clearLcdSegment(index: number): Promise { const lcdControl = this.#lcdControls.find((control) => control.id === index) if (!lcdControl) throw new Error(`Invalid lcd segment index ${index}`) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a192cf6..b042bf1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -82,6 +82,13 @@ export interface StreamDeck extends EventEmitter { */ getHidDeviceInfo(): Promise + /** + * Send a prepared buffer operation + * + * @param {PreparedBuffer} buffer The prepared buffer to draw + */ + sendPreparedBuffer(buffer: PreparedBuffer): Promise + /** * Fills the given key with a solid color. * @@ -121,13 +128,6 @@ export interface StreamDeck extends EventEmitter { jsonSafe?: boolean, ): Promise - /** - * Send a prepared fill key operation - * - * @param {PreparedBuffer} buffer The prepared buffer to draw - */ - sendPreparedFillKeyBuffer(buffer: PreparedBuffer): Promise - /** * Fills the whole panel with an image in a Buffer. * @@ -149,13 +149,6 @@ export interface StreamDeck extends EventEmitter { jsonSafe?: boolean, ): Promise - /** - * Send a prepared fill panel operation - * - * @param {PreparedBuffer} buffer The prepared buffer to draw - */ - sendPreparedFillPanelBuffer(buffer: PreparedBuffer): Promise - /** * Fill the whole lcd segment * @param {number} lcdIndex The id of the lcd segment to draw to @@ -228,13 +221,6 @@ export interface StreamDeck extends EventEmitter { jsonSafe?: boolean, ): Promise - /** - * Send a prepared fill region of the lcd operation - * - * @param {PreparedBuffer} buffer The prepared buffer to draw - */ - sendPreparedFillLcdRegion(buffer: PreparedBuffer): Promise - /** * Clear the lcd segment to black * @param {number} lcdIndex The id of the lcd segment to clear diff --git a/packages/node/examples/fill-panel-when-pressed-sharp.js b/packages/node/examples/fill-panel-when-pressed-sharp.js index 4beb028..42062c6 100644 --- a/packages/node/examples/fill-panel-when-pressed-sharp.js +++ b/packages/node/examples/fill-panel-when-pressed-sharp.js @@ -80,7 +80,7 @@ console.log('Press keys 0-7 to show the first image, and keys 8-15 to show the s color = [255, 0, 255] } - streamDeck.sendPreparedFillPanelBuffer(image).catch((e) => console.error('Fill failed:', e)) + streamDeck.sendPreparedBuffer(image).catch((e) => console.error('Fill failed:', e)) if (imageLcd) { streamDeck .fillLcd(lcdSegmentControl.id, imageLcd, { format: 'rgb' }) From f8af9ede87024e4fad7003f7ab3b39ae61c80b58 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Fri, 30 May 2025 23:08:04 +0100 Subject: [PATCH 7/7] wip: tests --- .../core/src/__tests__/preparedBuffer.spec.ts | 262 ++++++++++++++++++ packages/core/src/preparedBuffer.ts | 21 +- packages/core/src/types.ts | 2 + 3 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/__tests__/preparedBuffer.spec.ts diff --git a/packages/core/src/__tests__/preparedBuffer.spec.ts b/packages/core/src/__tests__/preparedBuffer.spec.ts new file mode 100644 index 0000000..41c8d46 --- /dev/null +++ b/packages/core/src/__tests__/preparedBuffer.spec.ts @@ -0,0 +1,262 @@ +import { wrapBufferToPreparedBuffer, unwrapPreparedBufferToBuffer } from '../preparedBuffer.js' +import { DeviceModelId } from '../id.js' + +describe('PreparedBuffer', () => { + const testModelId = DeviceModelId.ORIGINAL + const testType = 'test-type' + + describe('wrapBufferToPreparedBuffer and unwrapPreparedBufferToBuffer', () => { + test('round trip - binary safe (non-JSON)', () => { + // Create test data with various byte values including 0s and 255s + const originalBuffers = [ + new Uint8Array([0, 1, 2, 3, 255, 254, 253]), + new Uint8Array([128, 127, 126, 125, 100, 50, 0]), + new Uint8Array([255, 0, 255, 0, 255, 0, 255]), + ] + + // Wrap the buffers + const preparedBuffer = wrapBufferToPreparedBuffer( + testModelId, + testType, + originalBuffers, + false, // not JSON safe + ) + + // Unwrap the buffers + const unwrappedBuffers = unwrapPreparedBufferToBuffer(testModelId, preparedBuffer) + + // Verify the round trip is exact + expect(unwrappedBuffers).toHaveLength(originalBuffers.length) + for (let i = 0; i < originalBuffers.length; i++) { + // Convert to Uint8Array for comparison as Node.js may return Buffer + expect(new Uint8Array(unwrappedBuffers[i])).toEqual(originalBuffers[i]) + } + }) + + test('round trip - JSON safe', () => { + // Create test data with various byte values including 0s and 255s + const originalBuffers = [ + new Uint8Array([0, 1, 2, 3, 255, 254, 253]), + new Uint8Array([128, 127, 126, 125, 100, 50, 0]), + new Uint8Array([255, 0, 255, 0, 255, 0, 255]), + ] + + // Wrap the buffers with JSON safe encoding + const preparedBuffer = wrapBufferToPreparedBuffer( + testModelId, + testType, + originalBuffers, + true, // JSON safe + ) + + // Unwrap the buffers + const unwrappedBuffers = unwrapPreparedBufferToBuffer(testModelId, preparedBuffer) + + // Verify the round trip is exact + expect(unwrappedBuffers).toHaveLength(originalBuffers.length) + for (let i = 0; i < originalBuffers.length; i++) { + expect(new Uint8Array(unwrappedBuffers[i])).toEqual(originalBuffers[i]) + } + }) + + test('round trip - empty buffers', () => { + const originalBuffers = [new Uint8Array([]), new Uint8Array([])] + + const preparedBuffer = wrapBufferToPreparedBuffer(testModelId, testType, originalBuffers, false) + + const unwrappedBuffers = unwrapPreparedBufferToBuffer(testModelId, preparedBuffer) + + expect(unwrappedBuffers).toHaveLength(originalBuffers.length) + for (let i = 0; i < originalBuffers.length; i++) { + expect(new Uint8Array(unwrappedBuffers[i])).toEqual(originalBuffers[i]) + } + }) + + test('round trip - single byte buffers', () => { + const originalBuffers = [new Uint8Array([0]), new Uint8Array([255]), new Uint8Array([128])] + + const preparedBuffer = wrapBufferToPreparedBuffer(testModelId, testType, originalBuffers, true) + + const unwrappedBuffers = unwrapPreparedBufferToBuffer(testModelId, preparedBuffer) + + expect(unwrappedBuffers).toHaveLength(originalBuffers.length) + for (let i = 0; i < originalBuffers.length; i++) { + expect(new Uint8Array(unwrappedBuffers[i])).toEqual(originalBuffers[i]) + } + }) + + test('round trip - large buffers', () => { + // Create larger test buffers + const originalBuffers = [ + new Uint8Array(1000).map((_, i) => i % 256), + new Uint8Array(2000).map((_, i) => (i * 2) % 256), + ] + + const preparedBuffer = wrapBufferToPreparedBuffer(testModelId, testType, originalBuffers, false) + + const unwrappedBuffers = unwrapPreparedBufferToBuffer(testModelId, preparedBuffer) + + expect(unwrappedBuffers).toHaveLength(originalBuffers.length) + for (let i = 0; i < originalBuffers.length; i++) { + expect(new Uint8Array(unwrappedBuffers[i])).toEqual(originalBuffers[i]) + } + }) + + test('round trip - random data', () => { + // Create buffers with random data + const originalBuffers = [ + new Uint8Array(100).map(() => Math.floor(Math.random() * 256)), + new Uint8Array(200).map(() => Math.floor(Math.random() * 256)), + new Uint8Array(50).map(() => Math.floor(Math.random() * 256)), + ] + + // Test both JSON safe and non-JSON safe modes + for (const jsonSafe of [true, false]) { + const preparedBuffer = wrapBufferToPreparedBuffer(testModelId, testType, originalBuffers, jsonSafe) + + const unwrappedBuffers = unwrapPreparedBufferToBuffer(testModelId, preparedBuffer) + + expect(unwrappedBuffers).toHaveLength(originalBuffers.length) + for (let i = 0; i < originalBuffers.length; i++) { + expect(new Uint8Array(unwrappedBuffers[i])).toEqual(originalBuffers[i]) + } + } + }) + + test('model ID validation', () => { + const originalBuffers = [new Uint8Array([1, 2, 3])] + + const preparedBuffer = wrapBufferToPreparedBuffer(testModelId, testType, originalBuffers, false) + + // Should work with correct model ID + expect(() => unwrapPreparedBufferToBuffer(testModelId, preparedBuffer)).not.toThrow() + + // Should throw with wrong model ID + expect(() => unwrapPreparedBufferToBuffer(DeviceModelId.MINI, preparedBuffer)).toThrow( + 'Prepared buffer is for a different model!', + ) + }) + + test('JSON serialization and deserialization', () => { + const originalBuffers = [new Uint8Array([0, 1, 2, 3, 255, 254, 253]), new Uint8Array([128, 127, 126, 125])] + + // Create JSON-safe prepared buffer + const preparedBuffer = wrapBufferToPreparedBuffer(testModelId, testType, originalBuffers, true) + + // Serialize to JSON and back + const jsonString = JSON.stringify(preparedBuffer) + const deserializedBuffer = JSON.parse(jsonString) + + // Unwrap the deserialized buffer + const unwrappedBuffers = unwrapPreparedBufferToBuffer(testModelId, deserializedBuffer) + + // Verify the round trip through JSON is exact + expect(unwrappedBuffers).toHaveLength(originalBuffers.length) + for (let i = 0; i < originalBuffers.length; i++) { + expect(new Uint8Array(unwrappedBuffers[i])).toEqual(originalBuffers[i]) + } + }) + }) + + describe('wrapBufferToPreparedBuffer', () => { + test('JSON safe mode creates string arrays', () => { + const originalBuffers = [new Uint8Array([1, 2, 3])] + + const preparedBuffer = wrapBufferToPreparedBuffer(testModelId, testType, originalBuffers, true) + + const internal = preparedBuffer as any + expect(internal.do_not_touch).toHaveLength(1) + expect(typeof internal.do_not_touch[0]).toBe('string') + }) + + test('non-JSON safe mode preserves Uint8Array', () => { + const originalBuffers = [new Uint8Array([1, 2, 3])] + + const preparedBuffer = wrapBufferToPreparedBuffer(testModelId, testType, originalBuffers, false) + + const internal = preparedBuffer as any + expect(internal.do_not_touch).toHaveLength(1) + expect(internal.do_not_touch[0]).toBeInstanceOf(Uint8Array) + }) + }) + + describe('unwrapPreparedBufferToBuffer', () => { + test('throws error for invalid buffer type', () => { + // Create a malformed prepared buffer + const malformedBuffer = { + if_you_change_this_you_will_break_everything: 'test', + modelId: testModelId, + type: testType, + do_not_touch: [123], // Invalid type - should be string or Uint8Array + } as any + + expect(() => unwrapPreparedBufferToBuffer(testModelId, malformedBuffer)).toThrow( + 'Prepared buffer is not a string or Uint8Array!', + ) + }) + + test('handles mixed string and Uint8Array inputs', () => { + // Create a prepared buffer with mixed types (this could happen in edge cases) + const testBuffer = new Uint8Array([1, 2, 3]) + const base64String = Buffer.from(testBuffer).toString('base64') + + const mixedBuffer = { + if_you_change_this_you_will_break_everything: 'test', + modelId: testModelId, + type: testType, + do_not_touch: [testBuffer, base64String], + } as any + + const unwrappedBuffers = unwrapPreparedBufferToBuffer(testModelId, mixedBuffer) + + expect(unwrappedBuffers).toHaveLength(2) + expect(new Uint8Array(unwrappedBuffers[0])).toEqual(testBuffer) + expect(new Uint8Array(unwrappedBuffers[1])).toEqual(testBuffer) + }) + }) + + describe('edge cases and error conditions', () => { + test('empty buffer array', () => { + const originalBuffers: Uint8Array[] = [] + + const preparedBuffer = wrapBufferToPreparedBuffer(testModelId, testType, originalBuffers, false) + + const unwrappedBuffers = unwrapPreparedBufferToBuffer(testModelId, preparedBuffer) + + expect(unwrappedBuffers).toEqual([]) + }) + + test('preserves buffer order', () => { + const originalBuffers = [ + new Uint8Array([1]), + new Uint8Array([2]), + new Uint8Array([3]), + new Uint8Array([4]), + new Uint8Array([5]), + ] + + const preparedBuffer = wrapBufferToPreparedBuffer(testModelId, testType, originalBuffers, true) + + const unwrappedBuffers = unwrapPreparedBufferToBuffer(testModelId, preparedBuffer) + + expect(unwrappedBuffers).toHaveLength(5) + for (let i = 0; i < originalBuffers.length; i++) { + expect(new Uint8Array(unwrappedBuffers[i])).toEqual(originalBuffers[i]) + } + }) + + test('binary data with all byte values', () => { + // Create a buffer with all possible byte values (0-255) + const originalBuffers = [new Uint8Array(256).map((_, i) => i)] + + for (const jsonSafe of [true, false]) { + const preparedBuffer = wrapBufferToPreparedBuffer(testModelId, testType, originalBuffers, jsonSafe) + + const unwrappedBuffers = unwrapPreparedBufferToBuffer(testModelId, preparedBuffer) + + expect(unwrappedBuffers).toHaveLength(1) + expect(new Uint8Array(unwrappedBuffers[0])).toEqual(originalBuffers[0]) + } + }) + }) +}) diff --git a/packages/core/src/preparedBuffer.ts b/packages/core/src/preparedBuffer.ts index 8c04459..52587b5 100644 --- a/packages/core/src/preparedBuffer.ts +++ b/packages/core/src/preparedBuffer.ts @@ -29,8 +29,12 @@ export function wrapBufferToPreparedBuffer( let encodedBuffers: PreparedButtonDrawInternal['do_not_touch'] = buffers if (jsonSafe) { - const decoder = new TextDecoder() - encodedBuffers = buffers.map((b) => decoder.decode(b)) + // Use Base64 encoding for binary-safe string conversion + if (typeof Buffer !== 'undefined') { + encodedBuffers = buffers.map((b) => Buffer.from(b).toString('base64')) + } else { + encodedBuffers = buffers.map((b) => btoa(String.fromCharCode(...b))) + } } return { @@ -54,7 +58,18 @@ export function unwrapPreparedBufferToBuffer( return preparedInternal.do_not_touch.map((b) => { if (typeof b === 'string') { - return new TextEncoder().encode(b) + // Decode from Base64 for binary-safe conversion + if (typeof Buffer !== 'undefined') { + // Fast path for Node.js + return Buffer.from(b, 'base64') + } else { + // Browser fallback + return new Uint8Array( + atob(b) + .split('') + .map((char) => char.charCodeAt(0)), + ) + } } else if (b instanceof Uint8Array) { return b } else { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b042bf1..b2be4b7 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -142,6 +142,7 @@ export interface StreamDeck extends EventEmitter { * * @param {Buffer} imageBuffer The image to write * @param {Object} options Options to control the write + * @param {boolean} jsonSafe Whether the result should be packed to be safe to json serialize */ prepareFillPanelBuffer( imageBuffer: Uint8Array | Uint8ClampedArray, @@ -211,6 +212,7 @@ export interface StreamDeck extends EventEmitter { * @param {number} y The y position to draw to * @param {Buffer} imageBuffer The image to write * @param {Object} sourceOptions Options to control the write + * @param {boolean} jsonSafe Whether the result should be packed to be safe to json serialize */ prepareFillLcdRegion( lcdIndex: number,