diff --git a/.gitignore b/.gitignore index 610c90b..6a1d9b8 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,5 @@ typings/ !.yarn/releases !.yarn/sdks !.yarn/versions + +hack-* \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index fc8648c..7f61da7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -34,6 +34,7 @@ module.exports = { preset: 'ts-jest', moduleNameMapper: { + '@elgato-stream-deck/node-lib': '/packages/node-lib/src/lib.ts', '@elgato-stream-deck/(.+)': '/packages/$1/src', '^(..?/.+).js?$': '$1', }, diff --git a/package.json b/package.json index 9cbd034..e374b5f 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,9 @@ }, "workspaces": [ "packages/core", + "packages/node-lib", "packages/node", + "packages/tcp", "packages/webhid", "packages/webhid-demo" ], diff --git a/packages/core/src/__tests__/hid.ts b/packages/core/src/__tests__/hid.ts index 413683e..8dcbe3c 100644 --- a/packages/core/src/__tests__/hid.ts +++ b/packages/core/src/__tests__/hid.ts @@ -1,6 +1,6 @@ -import * as EventEmitter from 'eventemitter3' +import { EventEmitter } from 'eventemitter3' import type { EncodeJPEGHelper } from '../models/base.js' -import type { HIDDevice, HIDDeviceEvents, HIDDeviceInfo } from '../hid-device.js' +import type { ChildHIDDeviceInfo, HIDDevice, HIDDeviceEvents, HIDDeviceInfo } from '../hid-device.js' export class DummyHID extends EventEmitter implements HIDDevice { constructor( public readonly path: string, @@ -25,4 +25,8 @@ export class DummyHID extends EventEmitter implements HIDDevice public async getDeviceInfo(): Promise { throw new Error('Method not implemented.') } + + public async getChildDeviceInfo(): Promise { + throw new Error('Method not implemented.') + } } diff --git a/packages/core/src/__tests__/util.spec.ts b/packages/core/src/__tests__/util.spec.ts index 851e368..8f0e793 100644 --- a/packages/core/src/__tests__/util.spec.ts +++ b/packages/core/src/__tests__/util.spec.ts @@ -1,7 +1,7 @@ import { transformImageBuffer } from '../util.js' -function getSimpleBuffer(dim: number, components: 3 | 4): Buffer { - const buf = Buffer.alloc(dim * dim * components) +function getSimpleBuffer(width: number, height: number, components: 3 | 4): Buffer { + const buf = Buffer.alloc(width * height * components) for (let i = 0; i < buf.length; i++) { buf[i] = i } @@ -9,7 +9,7 @@ function getSimpleBuffer(dim: number, components: 3 | 4): Buffer { } describe('imageToByteArray', () => { test('basic rgb -> rgba', () => { - const srcBuffer = getSimpleBuffer(2, 3) + const srcBuffer = getSimpleBuffer(2, 2, 3) const res = transformImageBuffer( srcBuffer, { format: 'rgb', offset: 0, stride: 2 * 3 }, @@ -21,7 +21,7 @@ describe('imageToByteArray', () => { expect(res).toMatchSnapshot() }) test('basic rgb -> bgr', () => { - const srcBuffer = getSimpleBuffer(2, 3) + const srcBuffer = getSimpleBuffer(2, 2, 3) const res = transformImageBuffer( srcBuffer, { format: 'rgb', offset: 0, stride: 2 * 3 }, @@ -33,7 +33,7 @@ describe('imageToByteArray', () => { expect(res).toMatchSnapshot() }) test('basic bgra -> bgr', () => { - const srcBuffer = getSimpleBuffer(2, 4) + const srcBuffer = getSimpleBuffer(2, 2, 4) const res = transformImageBuffer( srcBuffer, { format: 'bgra', offset: 0, stride: 2 * 4 }, @@ -45,7 +45,7 @@ describe('imageToByteArray', () => { expect(res).toMatchSnapshot() }) test('basic bgra -> rgba', () => { - const srcBuffer = getSimpleBuffer(2, 4) + const srcBuffer = getSimpleBuffer(2, 2, 4) const res = transformImageBuffer( srcBuffer, { format: 'bgra', offset: 0, stride: 2 * 4 }, @@ -58,7 +58,7 @@ describe('imageToByteArray', () => { }) test('basic vflip', () => { - const srcBuffer = getSimpleBuffer(3, 3) + const srcBuffer = getSimpleBuffer(3, 3, 3) const res = transformImageBuffer( srcBuffer, { format: 'bgr', offset: 0, stride: 3 * 3 }, @@ -71,7 +71,7 @@ describe('imageToByteArray', () => { }) test('basic xflip', () => { - const srcBuffer = getSimpleBuffer(3, 3) + const srcBuffer = getSimpleBuffer(3, 3, 3) const res = transformImageBuffer( srcBuffer, { format: 'bgr', offset: 0, stride: 3 * 3 }, diff --git a/packages/core/src/controlDefinition.ts b/packages/core/src/controlDefinition.ts index dfb65e0..c385ebe 100644 --- a/packages/core/src/controlDefinition.ts +++ b/packages/core/src/controlDefinition.ts @@ -38,6 +38,12 @@ export interface StreamDeckEncoderControlDefinition extends StreamDeckControlDef index: number hidIndex: number + + /** Whether the encoder has an led */ + hasLed: boolean + + /** The number of steps in encoder led rings (if any) */ + ledRingSteps: number } export interface StreamDeckLcdSegmentControlDefinition extends StreamDeckControlDefinitionBase { diff --git a/packages/core/src/controlsGenerator.ts b/packages/core/src/controlsGenerator.ts index 2935c92..e05c51f 100644 --- a/packages/core/src/controlsGenerator.ts +++ b/packages/core/src/controlsGenerator.ts @@ -6,6 +6,7 @@ export function generateButtonsGrid( height: number, pixelSize: Dimension, rtl = false, + columnOffset = 0, ): StreamDeckButtonControlDefinition[] { const controls: StreamDeckButtonControlDefinition[] = [] @@ -17,7 +18,7 @@ export function generateButtonsGrid( controls.push({ type: 'button', row, - column, + column: column + columnOffset, index, hidIndex, feedbackType: 'lcd', diff --git a/packages/core/src/hid-device.ts b/packages/core/src/hid-device.ts index 3bda1b5..45f4dc1 100644 --- a/packages/core/src/hid-device.ts +++ b/packages/core/src/hid-device.ts @@ -1,4 +1,4 @@ -import type * as EventEmitter from 'eventemitter3' +import type { EventEmitter } from 'eventemitter3' export interface HIDDeviceEvents { error: [data: any] @@ -18,10 +18,17 @@ export interface HIDDevice extends EventEmitter { sendReports(buffers: Uint8Array[]): Promise getDeviceInfo(): Promise + + getChildDeviceInfo(): Promise } export interface HIDDeviceInfo { - path: string | undefined - productId: number - vendorId: number + readonly path: string | undefined + readonly productId: number + readonly vendorId: number +} + +export interface ChildHIDDeviceInfo extends HIDDeviceInfo { + readonly serialNumber: string + readonly tcpPort: number } diff --git a/packages/core/src/id.ts b/packages/core/src/id.ts index d8ef4fb..941ee12 100644 --- a/packages/core/src/id.ts +++ b/packages/core/src/id.ts @@ -13,4 +13,17 @@ export enum DeviceModelId { PEDAL = 'pedal', PLUS = 'plus', NEO = 'neo', + STUDIO = 'studio', +} + +export const MODEL_NAMES: { [key in DeviceModelId]: string } = { + [DeviceModelId.ORIGINAL]: 'Stream Deck', + [DeviceModelId.MINI]: 'Stream Deck Mini', + [DeviceModelId.XL]: 'Stream Deck XL', + [DeviceModelId.ORIGINALV2]: 'Stream Deck', + [DeviceModelId.ORIGINALMK2]: 'Stream Deck MK.2', + [DeviceModelId.PLUS]: 'Stream Deck +', + [DeviceModelId.PEDAL]: 'Stream Deck Pedal', + [DeviceModelId.NEO]: 'Stream Deck Neo', + [DeviceModelId.STUDIO]: 'Stream Deck Studio', } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 68d095e..7160989 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ import type { HIDDevice } from './hid-device.js' -import { DeviceModelId } from './id.js' +import { DeviceModelId, MODEL_NAMES } from './id.js' import type { StreamDeck } from './types.js' import type { OpenStreamDeckOptions } from './models/base.js' import { StreamDeckOriginalFactory } from './models/original.js' @@ -10,13 +10,17 @@ import { StreamDeckOriginalMK2Factory } from './models/original-mk2.js' import { StreamDeckPlusFactory } from './models/plus.js' import { StreamDeckPedalFactory } from './models/pedal.js' import { StreamDeckNeoFactory } from './models/neo.js' +import { StreamDeckStudioFactory } from './models/studio.js' +import type { PropertiesService } from './services/properties/interface.js' export * from './types.js' export * from './id.js' export * from './controlDefinition.js' -export { HIDDevice, HIDDeviceInfo, HIDDeviceEvents } from './hid-device.js' -export { OpenStreamDeckOptions } from './models/base.js' +export type { HIDDevice, HIDDeviceInfo, HIDDeviceEvents, ChildHIDDeviceInfo } from './hid-device.js' +export type { OpenStreamDeckOptions } from './models/base.js' export { StreamDeckProxy } from './proxy.js' +export type { PropertiesService } from './services/properties/interface.js' +export { uint8ArrayToDataView } from './util.js' /** Elgato vendor id */ export const VENDOR_ID = 0x0fd9 @@ -30,57 +34,92 @@ export interface DeviceModelSpec { id: DeviceModelId type: DeviceModelType productIds: number[] - factory: (device: HIDDevice, options: Required) => StreamDeck + productName: string + + factory: ( + device: HIDDevice, + options: Required, + propertiesService?: PropertiesService, + ) => StreamDeck + + hasNativeTcp: boolean } /** List of all the known models, and the classes to use them */ -export const DEVICE_MODELS2: { [key in DeviceModelId]: Omit } = { +export const DEVICE_MODELS2: { [key in DeviceModelId]: Omit } = { [DeviceModelId.ORIGINAL]: { type: DeviceModelType.STREAMDECK, productIds: [0x0060], factory: StreamDeckOriginalFactory, + + hasNativeTcp: false, }, [DeviceModelId.MINI]: { type: DeviceModelType.STREAMDECK, productIds: [0x0063, 0x0090], factory: StreamDeckMiniFactory, + + hasNativeTcp: false, }, [DeviceModelId.XL]: { type: DeviceModelType.STREAMDECK, productIds: [0x006c, 0x008f], factory: StreamDeckXLFactory, + + hasNativeTcp: false, }, [DeviceModelId.ORIGINALV2]: { type: DeviceModelType.STREAMDECK, productIds: [0x006d], factory: StreamDeckOriginalV2Factory, + + hasNativeTcp: false, }, [DeviceModelId.ORIGINALMK2]: { type: DeviceModelType.STREAMDECK, productIds: [0x0080], factory: StreamDeckOriginalMK2Factory, + + hasNativeTcp: false, }, [DeviceModelId.PLUS]: { type: DeviceModelType.STREAMDECK, productIds: [0x0084], factory: StreamDeckPlusFactory, + + hasNativeTcp: false, }, [DeviceModelId.PEDAL]: { type: DeviceModelType.PEDAL, productIds: [0x0086], factory: StreamDeckPedalFactory, + + hasNativeTcp: false, }, [DeviceModelId.NEO]: { type: DeviceModelType.STREAMDECK, productIds: [0x009a], factory: StreamDeckNeoFactory, + + hasNativeTcp: false, + }, + [DeviceModelId.STUDIO]: { + type: DeviceModelType.STREAMDECK, + productIds: [0x00aa], + factory: StreamDeckStudioFactory, + + hasNativeTcp: true, }, } /** @deprecated maybe? */ -export const DEVICE_MODELS: DeviceModelSpec[] = Object.entries>(DEVICE_MODELS2).map( - ([id, spec]) => ({ - id: id as any as DeviceModelId, +export const DEVICE_MODELS: DeviceModelSpec[] = Object.entries>( + DEVICE_MODELS2, +).map(([id, spec]) => { + const modelId = id as any as DeviceModelId + return { + id: modelId, + productName: MODEL_NAMES[modelId], ...spec, - }), -) + } +}) diff --git a/packages/core/src/models/base.ts b/packages/core/src/models/base.ts index a1f1130..4856f9f 100644 --- a/packages/core/src/models/base.ts +++ b/packages/core/src/models/base.ts @@ -1,4 +1,4 @@ -import * as EventEmitter from 'eventemitter3' +import { EventEmitter } from 'eventemitter3' import type { HIDDevice, HIDDeviceInfo } from '../hid-device.js' import type { DeviceModelId, Dimension, KeyIndex } from '../id.js' import type { @@ -7,6 +7,7 @@ import type { FillPanelOptions, StreamDeck, StreamDeckEvents, + StreamDeckTcpChildDeviceInfo, } from '../types.js' import type { ButtonsLcdDisplayService } from '../services/buttonsLcdDisplay/interface.js' import type { StreamDeckButtonControlDefinition, StreamDeckControlDefinition } from '../controlDefinition.js' @@ -14,6 +15,8 @@ import type { LcdSegmentDisplayService } from '../services/lcdSegmentDisplay/int import type { PropertiesService } from '../services/properties/interface.js' 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' export type EncodeJPEGHelper = (buffer: Uint8Array, width: number, height: number) => Promise @@ -39,6 +42,12 @@ export type StreamDeckProperties = Readonly<{ * @deprecated */ KEY_SPACING_VERTICAL: number + FULLSCREEN_PANELS: number + + HAS_NFC_READER: boolean + + /** Whether this device supports child devices */ + SUPPORTS_CHILD_DEVICES: boolean }> export interface StreamDeckServicesDefinition { @@ -48,6 +57,7 @@ export interface StreamDeckServicesDefinition { buttonsLcd: ButtonsLcdDisplayService inputService: StreamDeckInputService lcdSegmentDisplay: LcdSegmentDisplayService | null + encoderLed: EncoderLedService | null } export class StreamDeckBase extends EventEmitter implements StreamDeck { @@ -69,23 +79,34 @@ export class StreamDeckBase extends EventEmitter implements St return this.deviceProperties.PRODUCT_NAME } + get HAS_NFC_READER(): boolean { + return this.deviceProperties.HAS_NFC_READER + } + protected readonly device: HIDDevice protected readonly deviceProperties: Readonly + // readonly #options: Readonly> readonly #propertiesService: PropertiesService readonly #buttonsLcdService: ButtonsLcdDisplayService readonly #lcdSegmentDisplayService: LcdSegmentDisplayService | null readonly #inputService: StreamDeckInputService - // private readonly options: Readonly + readonly #encoderLedService: EncoderLedService | null - constructor(device: HIDDevice, _options: OpenStreamDeckOptions, services: StreamDeckServicesDefinition) { + constructor( + device: HIDDevice, + _options: Readonly>, + services: StreamDeckServicesDefinition, + ) { super() this.device = device this.deviceProperties = services.deviceProperties + // this.#options = options this.#propertiesService = services.properties this.#buttonsLcdService = services.buttonsLcd this.#lcdSegmentDisplayService = services.lcdSegmentDisplay this.#inputService = services.inputService + this.#encoderLedService = services.encoderLed // propogate events services.events?.listen((key, ...args) => this.emit(key, ...args)) @@ -195,4 +216,39 @@ export class StreamDeckBase extends EventEmitter implements St return this.#lcdSegmentDisplayService.clearLcdSegment(...args) } + + public async setEncoderColor( + ...args: Parameters + ): ReturnType { + if (!this.#encoderLedService) throw new Error('Not supported for this model') + + return this.#encoderLedService.setEncoderColor(...args) + } + public async setEncoderRingSingleColor( + ...args: Parameters + ): ReturnType { + if (!this.#encoderLedService) throw new Error('Not supported for this model') + + return this.#encoderLedService.setEncoderRingSingleColor(...args) + } + public async setEncoderRingColors( + ...args: Parameters + ): ReturnType { + if (!this.#encoderLedService) throw new Error('Not supported for this model') + + return this.#encoderLedService.setEncoderRingColors(...args) + } + + public async getChildDeviceInfo(): Promise { + const info = await this.device.getChildDeviceInfo() + if (!info || info.vendorId !== VENDOR_ID) return null + + const model = DEVICE_MODELS.find((m) => m.productIds.includes(info.productId)) + if (!model) return null + + return { + ...info, + model: model.id, + } + } } diff --git a/packages/core/src/models/generic-gen1.ts b/packages/core/src/models/generic-gen1.ts index 84707a1..180b390 100644 --- a/packages/core/src/models/generic-gen1.ts +++ b/packages/core/src/models/generic-gen1.ts @@ -14,10 +14,15 @@ function extendDevicePropertiesForGen1(rawProps: StreamDeckGen1Properties): Stre return { ...rawProps, KEY_DATA_OFFSET: 0, + HAS_NFC_READER: false, + SUPPORTS_CHILD_DEVICES: false, } } -export type StreamDeckGen1Properties = Omit +export type StreamDeckGen1Properties = Omit< + StreamDeckProperties, + 'KEY_DATA_OFFSET' | 'HAS_NFC_READER' | 'SUPPORTS_CHILD_DEVICES' +> export function StreamDeckGen1Factory( device: HIDDevice, @@ -43,5 +48,6 @@ export function StreamDeckGen1Factory( ), lcdSegmentDisplay: null, inputService: new ButtonOnlyInputService(fullProperties, events), + encoderLed: null, }) } diff --git a/packages/core/src/models/generic-gen2.ts b/packages/core/src/models/generic-gen2.ts index b53c743..2799ea2 100644 --- a/packages/core/src/models/generic-gen2.ts +++ b/packages/core/src/models/generic-gen2.ts @@ -8,6 +8,8 @@ import type { StreamDeckEvents } from '../types.js' import { Gen2PropertiesService } from '../services/properties/gen2.js' import { JpegButtonLcdImagePacker } from '../services/imagePacker/jpeg.js' import { Gen2InputService } from '../services/input/gen2.js' +import type { PropertiesService } from '../services/properties/interface.js' +import { EncoderLedService } from '../services/encoderLed.js' function extendDevicePropertiesForGen2(rawProps: StreamDeckGen2Properties): StreamDeckProperties { return { @@ -23,6 +25,7 @@ export function createBaseGen2Properties( device: HIDDevice, options: Required, properties: StreamDeckGen2Properties, + propertiesService: PropertiesService | null, disableXYFlip?: boolean, ): StreamDeckServicesDefinition { const fullProperties = extendDevicePropertiesForGen2(properties) @@ -32,7 +35,7 @@ export function createBaseGen2Properties( return { deviceProperties: fullProperties, events, - properties: new Gen2PropertiesService(device), + properties: propertiesService ?? new Gen2PropertiesService(device), buttonsLcd: new DefaultButtonsLcdService( new StreamdeckDefaultImageWriter(new StreamdeckGen2ImageHeaderGenerator()), new JpegButtonLcdImagePacker(options.encodeJPEG, !disableXYFlip), @@ -41,5 +44,6 @@ export function createBaseGen2Properties( ), lcdSegmentDisplay: null, inputService: new Gen2InputService(fullProperties, events), + encoderLed: new EncoderLedService(device, properties.CONTROLS), } } diff --git a/packages/core/src/models/mini.ts b/packages/core/src/models/mini.ts index e7a3387..e4b23b9 100644 --- a/packages/core/src/models/mini.ts +++ b/packages/core/src/models/mini.ts @@ -2,20 +2,22 @@ import type { HIDDevice } from '../hid-device.js' import type { OpenStreamDeckOptions, StreamDeckBase } from './base.js' import type { StreamDeckGen1Properties } from './generic-gen1.js' import { StreamDeckGen1Factory } from './generic-gen1.js' -import { DeviceModelId } from '../id.js' +import { DeviceModelId, MODEL_NAMES } from '../id.js' import { freezeDefinitions, generateButtonsGrid } from '../controlsGenerator.js' import { StreamdeckDefaultImageWriter } from '../services/imageWriter/imageWriter.js' import { StreamdeckGen1ImageHeaderGenerator } from '../services/imageWriter/headerGenerator.js' const miniProperties: StreamDeckGen1Properties = { MODEL: DeviceModelId.MINI, - PRODUCT_NAME: 'Stream Deck Mini', + PRODUCT_NAME: MODEL_NAMES[DeviceModelId.MINI], SUPPORTS_RGB_KEY_FILL: false, // TODO - verify this CONTROLS: freezeDefinitions(generateButtonsGrid(3, 2, { width: 80, height: 80 })), KEY_SPACING_HORIZONTAL: 28, KEY_SPACING_VERTICAL: 28, + + FULLSCREEN_PANELS: 0, } export function StreamDeckMiniFactory(device: HIDDevice, options: Required): StreamDeckBase { diff --git a/packages/core/src/models/neo.ts b/packages/core/src/models/neo.ts index 35b075c..7b21d19 100644 --- a/packages/core/src/models/neo.ts +++ b/packages/core/src/models/neo.ts @@ -1,7 +1,7 @@ import type { HIDDevice } from '../hid-device.js' import type { OpenStreamDeckOptions } from './base.js' import { StreamDeckBase } from './base.js' -import { DeviceModelId } from '../id.js' +import { DeviceModelId, MODEL_NAMES } from '../id.js' import type { StreamDeckGen2Properties } from './generic-gen2.js' import { createBaseGen2Properties } from './generic-gen2.js' import { freezeDefinitions, generateButtonsGrid } from '../controlsGenerator.js' @@ -46,19 +46,23 @@ neoControls.push( const neoProperties: StreamDeckGen2Properties = { MODEL: DeviceModelId.NEO, - PRODUCT_NAME: 'Stream Deck Neo', + PRODUCT_NAME: MODEL_NAMES[DeviceModelId.NEO], CONTROLS: freezeDefinitions(neoControls), KEY_SPACING_HORIZONTAL: 30, KEY_SPACING_VERTICAL: 30, + + FULLSCREEN_PANELS: 0, + HAS_NFC_READER: false, + SUPPORTS_CHILD_DEVICES: false, } const lcdSegmentControls = neoProperties.CONTROLS.filter( (control): control is StreamDeckLcdSegmentControlDefinition => control.type === 'lcd-segment', ) export function StreamDeckNeoFactory(device: HIDDevice, options: Required): StreamDeckBase { - const services = createBaseGen2Properties(device, options, neoProperties) + const services = createBaseGen2Properties(device, options, neoProperties, null) services.lcdSegmentDisplay = new StreamDeckNeoLcdService(options.encodeJPEG, device, lcdSegmentControls) return new StreamDeckBase(device, options, services) diff --git a/packages/core/src/models/original-mk2.ts b/packages/core/src/models/original-mk2.ts index 743231a..9cbcebe 100644 --- a/packages/core/src/models/original-mk2.ts +++ b/packages/core/src/models/original-mk2.ts @@ -3,24 +3,28 @@ import type { OpenStreamDeckOptions } from './base.js' import { StreamDeckBase } from './base.js' import type { StreamDeckGen2Properties } from './generic-gen2.js' import { createBaseGen2Properties } from './generic-gen2.js' -import { DeviceModelId } from '../id.js' +import { DeviceModelId, MODEL_NAMES } from '../id.js' import { freezeDefinitions, generateButtonsGrid } from '../controlsGenerator.js' const origMK2Properties: StreamDeckGen2Properties = { MODEL: DeviceModelId.ORIGINALMK2, - PRODUCT_NAME: 'Stream Deck MK2', + PRODUCT_NAME: MODEL_NAMES[DeviceModelId.ORIGINALMK2], CONTROLS: freezeDefinitions(generateButtonsGrid(5, 3, { width: 72, height: 72 })), KEY_SPACING_HORIZONTAL: 25, KEY_SPACING_VERTICAL: 25, + + FULLSCREEN_PANELS: 0, + HAS_NFC_READER: false, + SUPPORTS_CHILD_DEVICES: false, } export function StreamDeckOriginalMK2Factory( device: HIDDevice, options: Required, ): StreamDeckBase { - const services = createBaseGen2Properties(device, options, origMK2Properties) + const services = createBaseGen2Properties(device, options, origMK2Properties, null) return new StreamDeckBase(device, options, services) } diff --git a/packages/core/src/models/original.ts b/packages/core/src/models/original.ts index af11855..bd6cf14 100644 --- a/packages/core/src/models/original.ts +++ b/packages/core/src/models/original.ts @@ -2,19 +2,21 @@ import type { HIDDevice } from '../hid-device.js' import type { OpenStreamDeckOptions, StreamDeckBase } from './base.js' import type { StreamDeckGen1Properties } from './generic-gen1.js' import { StreamDeckGen1Factory } from './generic-gen1.js' -import { DeviceModelId } from '../id.js' +import { DeviceModelId, MODEL_NAMES } from '../id.js' import { StreamdeckOriginalImageWriter } from '../services/imageWriter/imageWriter.js' import { freezeDefinitions, generateButtonsGrid } from '../controlsGenerator.js' const originalProperties: StreamDeckGen1Properties = { MODEL: DeviceModelId.ORIGINAL, - PRODUCT_NAME: 'Stream Deck', + PRODUCT_NAME: MODEL_NAMES[DeviceModelId.ORIGINAL], SUPPORTS_RGB_KEY_FILL: false, CONTROLS: freezeDefinitions(generateButtonsGrid(5, 3, { width: 72, height: 72 }, true)), KEY_SPACING_HORIZONTAL: 25, KEY_SPACING_VERTICAL: 25, + + FULLSCREEN_PANELS: 0, } export function StreamDeckOriginalFactory(device: HIDDevice, options: Required): StreamDeckBase { diff --git a/packages/core/src/models/originalv2.ts b/packages/core/src/models/originalv2.ts index ac4f4ec..db07822 100644 --- a/packages/core/src/models/originalv2.ts +++ b/packages/core/src/models/originalv2.ts @@ -3,25 +3,29 @@ import type { OpenStreamDeckOptions } from './base.js' import { StreamDeckBase } from './base.js' import type { StreamDeckGen2Properties } from './generic-gen2.js' import { createBaseGen2Properties } from './generic-gen2.js' -import { DeviceModelId } from '../id.js' +import { DeviceModelId, MODEL_NAMES } from '../id.js' import { freezeDefinitions, generateButtonsGrid } from '../controlsGenerator.js' const origV2Properties: StreamDeckGen2Properties = { MODEL: DeviceModelId.ORIGINALV2, - PRODUCT_NAME: 'Stream Deck', + PRODUCT_NAME: MODEL_NAMES[DeviceModelId.ORIGINALV2], // SUPPORTS_RGB_KEY_FILL: false, // TODO - verify SUPPORTS_RGB_KEY_FILL CONTROLS: freezeDefinitions(generateButtonsGrid(5, 3, { width: 72, height: 72 })), KEY_SPACING_HORIZONTAL: 25, KEY_SPACING_VERTICAL: 25, + + FULLSCREEN_PANELS: 0, + HAS_NFC_READER: false, + SUPPORTS_CHILD_DEVICES: false, } export function StreamDeckOriginalV2Factory( device: HIDDevice, options: Required, ): StreamDeckBase { - const services = createBaseGen2Properties(device, options, origV2Properties) + const services = createBaseGen2Properties(device, options, origV2Properties, null) return new StreamDeckBase(device, options, services) } diff --git a/packages/core/src/models/pedal.ts b/packages/core/src/models/pedal.ts index 81a015a..1f4ff41 100644 --- a/packages/core/src/models/pedal.ts +++ b/packages/core/src/models/pedal.ts @@ -1,7 +1,7 @@ import type { HIDDevice } from '../hid-device.js' import type { OpenStreamDeckOptions, StreamDeckProperties } from './base.js' import { StreamDeckBase } from './base.js' -import { DeviceModelId } from '../id.js' +import { DeviceModelId, MODEL_NAMES } from '../id.js' import type { StreamDeckControlDefinition } from '../controlDefinition.js' import { freezeDefinitions } from '../controlsGenerator.js' import { PedalPropertiesService } from '../services/properties/pedal.js' @@ -39,7 +39,7 @@ const pedalControls: StreamDeckControlDefinition[] = [ const pedalProperties: StreamDeckProperties = { MODEL: DeviceModelId.PEDAL, - PRODUCT_NAME: 'Stream Deck Pedal', + PRODUCT_NAME: MODEL_NAMES[DeviceModelId.PEDAL], KEY_DATA_OFFSET: 3, SUPPORTS_RGB_KEY_FILL: false, @@ -47,6 +47,10 @@ const pedalProperties: StreamDeckProperties = { KEY_SPACING_HORIZONTAL: 0, KEY_SPACING_VERTICAL: 0, + + FULLSCREEN_PANELS: 0, + HAS_NFC_READER: false, + SUPPORTS_CHILD_DEVICES: false, } export function StreamDeckPedalFactory(device: HIDDevice, options: Required): StreamDeckBase { @@ -59,5 +63,6 @@ export function StreamDeckPedalFactory(device: HIDDevice, options: Required control.type === 'lcd-segment', ) export function StreamDeckPlusFactory(device: HIDDevice, options: Required): StreamDeckBase { - const services = createBaseGen2Properties(device, options, plusProperties, true) + const services = createBaseGen2Properties(device, options, plusProperties, null, true) services.lcdSegmentDisplay = new StreamDeckPlusLcdService(options.encodeJPEG, device, lcdSegmentControls) return new StreamDeckBase(device, options, services) diff --git a/packages/core/src/models/studio.ts b/packages/core/src/models/studio.ts new file mode 100644 index 0000000..9d1dc0a --- /dev/null +++ b/packages/core/src/models/studio.ts @@ -0,0 +1,55 @@ +import type { HIDDevice } from '../hid-device.js' +import { StreamDeckBase, type OpenStreamDeckOptions } from './base.js' +import { createBaseGen2Properties, type StreamDeckGen2Properties } from './generic-gen2.js' +import { DeviceModelId, MODEL_NAMES } from '../id.js' +import { freezeDefinitions, generateButtonsGrid } from '../controlsGenerator.js' +import type { StreamDeckControlDefinition } from '../controlDefinition.js' +import type { PropertiesService } from '../services/properties/interface.js' + +const studioControls: StreamDeckControlDefinition[] = [ + { + type: 'encoder', + row: 0, + column: 0, + index: 0, + hidIndex: 0, + + hasLed: true, + ledRingSteps: 24, + }, + ...generateButtonsGrid(16, 2, { width: 144, height: 112 }, false, 1), + { + type: 'encoder', + row: 0, + column: 17, + index: 1, + hidIndex: 1, + + hasLed: true, + ledRingSteps: 24, + }, +] + +export const studioProperties: StreamDeckGen2Properties = { + MODEL: DeviceModelId.STUDIO, + PRODUCT_NAME: MODEL_NAMES[DeviceModelId.STUDIO], + + CONTROLS: freezeDefinitions(studioControls), + + KEY_SPACING_HORIZONTAL: 0, // TODO + KEY_SPACING_VERTICAL: 0, // TODO + + FULLSCREEN_PANELS: 2, + + HAS_NFC_READER: true, + SUPPORTS_CHILD_DEVICES: true, +} + +export function StreamDeckStudioFactory( + device: HIDDevice, + options: Required, + propertiesService?: PropertiesService, +): StreamDeckBase { + const services = createBaseGen2Properties(device, options, studioProperties, propertiesService ?? null, true) + return new StreamDeckBase(device, options, services) +} diff --git a/packages/core/src/models/xl.ts b/packages/core/src/models/xl.ts index c846f86..6ffc1ed 100644 --- a/packages/core/src/models/xl.ts +++ b/packages/core/src/models/xl.ts @@ -3,21 +3,25 @@ import type { OpenStreamDeckOptions } from './base.js' import { StreamDeckBase } from './base.js' import type { StreamDeckGen2Properties } from './generic-gen2.js' import { createBaseGen2Properties } from './generic-gen2.js' -import { DeviceModelId } from '../id.js' +import { DeviceModelId, MODEL_NAMES } from '../id.js' import { freezeDefinitions, generateButtonsGrid } from '../controlsGenerator.js' const xlProperties: StreamDeckGen2Properties = { MODEL: DeviceModelId.XL, - PRODUCT_NAME: 'Stream Deck XL', + PRODUCT_NAME: MODEL_NAMES[DeviceModelId.XL], CONTROLS: freezeDefinitions(generateButtonsGrid(8, 4, { width: 96, height: 96 })), KEY_SPACING_HORIZONTAL: 32, KEY_SPACING_VERTICAL: 39, + + FULLSCREEN_PANELS: 0, + HAS_NFC_READER: false, + SUPPORTS_CHILD_DEVICES: false, } export function StreamDeckXLFactory(device: HIDDevice, options: Required): StreamDeckBase { - const services = createBaseGen2Properties(device, options, xlProperties) + const services = createBaseGen2Properties(device, options, xlProperties, null) return new StreamDeckBase(device, options, services) } diff --git a/packages/core/src/proxy.ts b/packages/core/src/proxy.ts index a96b2ea..9ac6b76 100644 --- a/packages/core/src/proxy.ts +++ b/packages/core/src/proxy.ts @@ -1,4 +1,4 @@ -import type * as EventEmitter from 'eventemitter3' +import type { EventEmitter } from 'eventemitter3' import type { DeviceModelId } from './id.js' import type { StreamDeck, StreamDeckEvents } from './types.js' import type { StreamDeckControlDefinition } from './controlDefinition.js' @@ -9,7 +9,7 @@ import type { StreamDeckControlDefinition } from './controlDefinition.js' */ export class StreamDeckProxy implements StreamDeck { - protected device: StreamDeck + protected readonly device: StreamDeck constructor(device: StreamDeck) { this.device = device @@ -30,6 +30,9 @@ export class StreamDeckProxy implements StreamDeck { public get PRODUCT_NAME(): string { return this.device.PRODUCT_NAME } + public get HAS_NFC_READER(): boolean { + return this.device.HAS_NFC_READER + } public calculateFillPanelDimensions( ...args: Parameters @@ -83,6 +86,24 @@ export class StreamDeckProxy implements StreamDeck { return this.device.fillLcd(...args) } + public async setEncoderColor( + ...args: Parameters + ): ReturnType { + return this.device.setEncoderColor(...args) + } + + public async setEncoderRingSingleColor( + ...args: Parameters + ): ReturnType { + return this.device.setEncoderRingSingleColor(...args) + } + + public async setEncoderRingColors( + ...args: Parameters + ): ReturnType { + return this.device.setEncoderRingColors(...args) + } + public async fillLcdRegion( ...args: Parameters ): ReturnType { @@ -95,6 +116,12 @@ export class StreamDeckProxy implements StreamDeck { return this.device.clearLcdSegment(...args) } + public async getChildDeviceInfo( + ...args: Parameters + ): ReturnType { + return this.device.getChildDeviceInfo(...args) + } + /** * EventEmitter */ diff --git a/packages/core/src/services/buttonsLcdDisplay/default.ts b/packages/core/src/services/buttonsLcdDisplay/default.ts index fd3e821..86a4977 100644 --- a/packages/core/src/services/buttonsLcdDisplay/default.ts +++ b/packages/core/src/services/buttonsLcdDisplay/default.ts @@ -13,13 +13,13 @@ import type { ButtonLcdImagePacker, InternalFillImageOptions } from '../imagePac export class DefaultButtonsLcdService implements ButtonsLcdDisplayService { readonly #imageWriter: StreamdeckImageWriter readonly #imagePacker: ButtonLcdImagePacker - readonly #device: HIDDevice + readonly #device: Pick readonly #deviceProperties: Readonly constructor( imageWriter: StreamdeckImageWriter, imagePacker: ButtonLcdImagePacker, - device: HIDDevice, + device: Pick, deviceProperties: Readonly, ) { this.#imageWriter = imageWriter @@ -82,31 +82,43 @@ export class DefaultButtonsLcdService implements ButtonsLcdDisplayService { public async clearPanel(): Promise { const ps: Promise[] = [] - for (const control of this.#deviceProperties.CONTROLS) { - if (control.type !== 'button') continue + if (this.#deviceProperties.FULLSCREEN_PANELS > 0) { + // TODO - should this be a separate property? + for (let screenIndex = 0; screenIndex < this.#deviceProperties.FULLSCREEN_PANELS; screenIndex++) { + const buffer = new Uint8Array(1024) + buffer[0] = 0x03 + buffer[1] = 0x05 + buffer[2] = screenIndex // TODO - index + ps.push(this.#device.sendReports([buffer])) + } + // TODO - clear rgb? + } else { + for (const control of this.#deviceProperties.CONTROLS) { + if (control.type !== 'button') continue - switch (control.feedbackType) { - case 'rgb': - ps.push(this.sendKeyRgb(control.hidIndex, 0, 0, 0)) - break - case 'lcd': - if (this.#deviceProperties.SUPPORTS_RGB_KEY_FILL) { + switch (control.feedbackType) { + case 'rgb': ps.push(this.sendKeyRgb(control.hidIndex, 0, 0, 0)) - } else { - const pixels = new Uint8Array(control.pixelSize.width * control.pixelSize.height * 3) - ps.push( - this.fillImageRangeControl(control, pixels, { - format: 'rgb', - offset: 0, - stride: control.pixelSize.width * 3, - }), - ) - } - - break - case 'none': - // Do nothing - break + break + case 'lcd': + if (this.#deviceProperties.SUPPORTS_RGB_KEY_FILL) { + ps.push(this.sendKeyRgb(control.hidIndex, 0, 0, 0)) + } else { + const pixels = new Uint8Array(control.pixelSize.width * control.pixelSize.height * 3) + ps.push( + this.fillImageRangeControl(control, pixels, { + format: 'rgb', + offset: 0, + stride: control.pixelSize.width * 3, + }), + ) + } + + break + case 'none': + // Do nothing + break + } } } diff --git a/packages/core/src/services/encoderLed.ts b/packages/core/src/services/encoderLed.ts new file mode 100644 index 0000000..bc0f845 --- /dev/null +++ b/packages/core/src/services/encoderLed.ts @@ -0,0 +1,86 @@ +import type { EncoderIndex } from '../id.js' +import type { StreamDeckControlDefinition, StreamDeckEncoderControlDefinition } from '../controlDefinition.js' +import type { HIDDevice } from '../hid-device.js' + +export class EncoderLedService { + readonly #device: HIDDevice + readonly #encoderControls: Readonly + + constructor(device: HIDDevice, allControls: Readonly) { + this.#device = device + this.#encoderControls = allControls.filter( + (control): control is StreamDeckEncoderControlDefinition => control.type === 'encoder', + ) + } + + public async clearAll(): Promise { + const ps: Array> = [] + + for (const control of this.#encoderControls) { + if (control.hasLed) ps.push(this.setEncoderColor(control.index, 0, 0, 0)) + if (control.ledRingSteps > 0) ps.push(this.setEncoderRingSingleColor(control.index, 0, 0, 0)) + } + + await Promise.all(ps) + } + + public async setEncoderColor(encoder: EncoderIndex, red: number, green: number, blue: number): Promise { + const control = this.#encoderControls.find((c) => c.index === encoder) + if (!control) throw new Error(`Invalid encoder index ${encoder}`) + + if (!control.hasLed) throw new Error('Encoder does not have an LED') + + const buffer = new Uint8Array(1024) + buffer[0] = 0x02 + buffer[1] = 0x10 + buffer[2] = encoder + buffer[3] = red + buffer[4] = green + buffer[5] = blue + await this.#device.sendReports([buffer]) + } + + public async setEncoderRingSingleColor( + encoder: EncoderIndex, + red: number, + green: number, + blue: number, + ): Promise { + const control = this.#encoderControls.find((c) => c.index === encoder) + if (!control) throw new Error(`Invalid encoder index ${encoder}`) + + if (control.ledRingSteps <= 0) throw new Error('Encoder does not have an LED ring') + + const buffer = new Uint8Array(1024) + buffer[0] = 0x02 + buffer[1] = 0x0f + buffer[2] = encoder + for (let i = 0; i < control.ledRingSteps; i++) { + const offset = 3 + i * 3 + buffer[offset] = red + buffer[offset + 1] = green + buffer[offset + 2] = blue + } + + await this.#device.sendReports([buffer]) + } + + public async setEncoderRingColors(encoder: EncoderIndex, colors: number[] | Uint8Array): Promise { + const control = this.#encoderControls.find((c) => c.index === encoder) + if (!control) throw new Error(`Invalid encoder index ${encoder}`) + + if (control.ledRingSteps <= 0) throw new Error('Encoder does not have an LED ring') + + if (colors.length !== control.ledRingSteps * 3) throw new Error('Invalid colors length') + + const colorsBuffer = colors instanceof Uint8Array ? colors : new Uint8Array(colors) + + const buffer = new Uint8Array(1024) + buffer[0] = 0x02 + buffer[1] = 0x0f + buffer[2] = encoder + buffer.set(colorsBuffer, 3) + + await this.#device.sendReports([buffer]) + } +} diff --git a/packages/core/src/services/input/gen1.ts b/packages/core/src/services/input/gen1.ts index 9c802b9..a1ea82a 100644 --- a/packages/core/src/services/input/gen1.ts +++ b/packages/core/src/services/input/gen1.ts @@ -5,24 +5,24 @@ import type { CallbackHook } from '../callback-hook.js' import type { StreamDeckButtonControlDefinition } from '../../controlDefinition.js' export class ButtonOnlyInputService implements StreamDeckInputService { - readonly #deviceProperties: Readonly + protected readonly deviceProperties: Readonly readonly #keyState: boolean[] readonly #eventSource: CallbackHook constructor(deviceProperties: Readonly, eventSource: CallbackHook) { - this.#deviceProperties = deviceProperties + this.deviceProperties = deviceProperties this.#eventSource = eventSource - const maxButtonIndex = this.#deviceProperties.CONTROLS.filter( + const maxButtonIndex = this.deviceProperties.CONTROLS.filter( (control): control is StreamDeckButtonControlDefinition => control.type === 'button', ).map((control) => control.index) this.#keyState = new Array(Math.max(-1, ...maxButtonIndex) + 1).fill(false) } handleInput(data: Uint8Array): void { - const dataOffset = this.#deviceProperties.KEY_DATA_OFFSET || 0 + const dataOffset = this.deviceProperties.KEY_DATA_OFFSET || 0 - for (const control of this.#deviceProperties.CONTROLS) { + for (const control of this.deviceProperties.CONTROLS) { if (control.type !== 'button') continue const keyPressed = Boolean(data[dataOffset + control.hidIndex]) diff --git a/packages/core/src/services/input/gen2.ts b/packages/core/src/services/input/gen2.ts index 10c88e9..ef553ad 100644 --- a/packages/core/src/services/input/gen2.ts +++ b/packages/core/src/services/input/gen2.ts @@ -41,6 +41,9 @@ export class Gen2InputService extends ButtonOnlyInputService { case 0x03: // Encoder this.#handleEncoderInput(data) break + case 0x04: // NFC + this.#handleNfcRead(data) + break } } @@ -101,4 +104,13 @@ export class Gen2InputService extends ButtonOnlyInputService { break } } + + #handleNfcRead(data: Uint8Array): void { + if (!this.deviceProperties.HAS_NFC_READER) return + + const length = data[1] + data[2] * 256 + const id = new TextDecoder('ascii').decode(data.subarray(3, 3 + length)) + + this.#eventSource.emit('nfcRead', id) + } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0b1e64f..1800a78 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,5 +1,5 @@ -import type * as EventEmitter from 'eventemitter3' -import type { DeviceModelId, Dimension, KeyIndex } from './id.js' +import type { EventEmitter } from 'eventemitter3' +import type { DeviceModelId, Dimension, EncoderIndex, KeyIndex } from './id.js' import type { HIDDeviceInfo } from './hid-device.js' import type { StreamDeckButtonControlDefinition, @@ -8,6 +8,12 @@ import type { StreamDeckLcdSegmentControlDefinition, } from './controlDefinition.js' +export interface StreamDeckTcpChildDeviceInfo extends HIDDeviceInfo { + readonly model: DeviceModelId + readonly serialNumber: string + readonly tcpPort: number +} + export interface FillImageOptions { format: 'rgb' | 'rgba' | 'bgr' | 'bgra' } @@ -35,6 +41,8 @@ export type StreamDeckEvents = { lcdShortPress: [control: StreamDeckLcdSegmentControlDefinition, position: LcdPosition] lcdLongPress: [control: StreamDeckLcdSegmentControlDefinition, position: LcdPosition] lcdSwipe: [control: StreamDeckLcdSegmentControlDefinition, from: LcdPosition, to: LcdPosition] + + nfcRead: [id: string] } export interface StreamDeck extends EventEmitter { @@ -53,6 +61,9 @@ export interface StreamDeck extends EventEmitter { /** The name of the product/model */ readonly PRODUCT_NAME: string + /** Whether this device has a nfc reader */ + readonly HAS_NFC_READER: boolean + /** * Calculate the dimensions to use for `fillPanelBuffer`, to fill the whole button lcd panel with a single image. * @param options Options to control the write @@ -113,6 +124,31 @@ export interface StreamDeck extends EventEmitter { sourceOptions: FillImageOptions, ): Promise + /** + * Fills the primary led of an encoder + * @param {number} index The encoder to fill + * @param {number} r The color's red value. 0 - 255 + * @param {number} g The color's green value. 0 - 255 + * @param {number} b The color's blue value. 0 -255 + */ + setEncoderColor(index: EncoderIndex, r: number, g: number, b: number): Promise + + /** + * Fills the led ring of an encoder with a single color + * @param {number} index The encoder to fill + * @param {number} r The color's red value. 0 - 255 + * @param {number} g The color's green value. 0 - 255 + * @param {number} b The color's blue value. 0 -255 + */ + setEncoderRingSingleColor(index: EncoderIndex, r: number, g: number, b: number): Promise + + /** + * Fill the led ring of an encoder + * @param index The encoder to fill + * @param colors rgb packed pixel values for the encoder ring + */ + setEncoderRingColors(index: EncoderIndex, colors: number[] | Uint8Array): Promise + /** * Fill a region of the lcd segment, ignoring the boundaries of the encoders * @param {number} lcdIndex The id of the lcd segment to draw to @@ -168,4 +204,6 @@ export interface StreamDeck extends EventEmitter { * Get serial number from Stream Deck */ getSerialNumber(): Promise + + getChildDeviceInfo(): Promise } diff --git a/packages/node-lib/CHANGELOG.md b/packages/node-lib/CHANGELOG.md new file mode 100644 index 0000000..e4d87c4 --- /dev/null +++ b/packages/node-lib/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. diff --git a/packages/node-lib/LICENSE b/packages/node-lib/LICENSE new file mode 100644 index 0000000..ebc5513 --- /dev/null +++ b/packages/node-lib/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Julian Waller + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/node-lib/README.md b/packages/node-lib/README.md new file mode 100644 index 0000000..9d97977 --- /dev/null +++ b/packages/node-lib/README.md @@ -0,0 +1,20 @@ +# @elgato-stream-deck/node-lib + +![Node CI](https://github.com/Julusian/node-elgato-stream-deck/workflows/Node%20CI/badge.svg) +[![codecov](https://codecov.io/gh/Julusian/node-elgato-stream-deck/branch/master/graph/badge.svg?token=Hl4QXGZJMF)](https://codecov.io/gh/Julusian/node-elgato-stream-deck) + +[![npm version](https://img.shields.io/npm/v/@elgato-stream-deck/node-lib.svg)](https://npm.im/@elgato-stream-deck/node-lib) +[![license](https://img.shields.io/npm/l/@elgato-stream-deck/node-lib.svg)](https://npm.im/@elgato-stream-deck/node-lib) + +[`@elgato-stream-deck/node-lib`](https://github.com/julusian/node-elgato-stream-deck) is an internal library, used for interfacing +with the various models of the [Elgato Stream Deck](https://www.elgato.com/en/gaming/stream-deck) through node. + +## Intended use + +This is an internal helper library of the @elgato-stream-deck projects, it should not be used by others or directly. + +## Contributing + +The elgato-stream-deck team enthusiastically welcomes contributions and project participation! There's a bunch of things you can do if you want to contribute! Please don't hesitate to jump in if you'd like to, or even ask us questions if something isn't clear. + +Please refer to the [Changelog](CHANGELOG.md) for project history details, too. diff --git a/packages/node-lib/package.json b/packages/node-lib/package.json new file mode 100644 index 0000000..d4c3400 --- /dev/null +++ b/packages/node-lib/package.json @@ -0,0 +1,45 @@ +{ + "name": "@elgato-stream-deck/node-lib", + "version": "7.0.0-0", + "description": "Helpers for a npm module for interfacing with the Elgato Stream Deck", + "main": "dist/lib.js", + "typings": "dist/lib.d.ts", + "license": "MIT", + "homepage": "https://github.com/julusian/node-elgato-stream-deck#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/julusian/node-elgato-stream-deck.git" + }, + "bugs": { + "url": "https://github.com/julusian/node-elgato-stream-deck/issues" + }, + "author": { + "name": "Julian Waller", + "email": "git@julusian.co.uk" + }, + "keywords": [ + "elgato", + "stream", + "deck", + "streamdeck", + "hid", + "usb", + "hardware", + "interface", + "controller" + ], + "files": [ + "dist", + "udev" + ], + "engines": { + "node": ">=18.18" + }, + "dependencies": { + "jpeg-js": "^0.4.4", + "tslib": "^2.6.3" + }, + "peerDependencies": { + "@julusian/jpeg-turbo": "^1.1.2 || ^2.0.0" + } +} diff --git a/packages/node-lib/src/__tests__/helpers.ts b/packages/node-lib/src/__tests__/helpers.ts new file mode 100644 index 0000000..cec1c24 --- /dev/null +++ b/packages/node-lib/src/__tests__/helpers.ts @@ -0,0 +1,8 @@ +import * as fs from 'fs' +import * as path from 'path' + +export function readFixtureJSON(fileName: string): Buffer { + const filePath = path.resolve(__dirname, '../../../../fixtures', fileName) + const fileData = fs.readFileSync(filePath) + return Buffer.from(JSON.parse(fileData.toString()) as Array) +} diff --git a/packages/node/src/__tests__/jpeg-encoding.spec.ts b/packages/node-lib/src/__tests__/jpeg-encoding.spec.ts similarity index 100% rename from packages/node/src/__tests__/jpeg-encoding.spec.ts rename to packages/node-lib/src/__tests__/jpeg-encoding.spec.ts diff --git a/packages/node/src/__tests__/jpeg-library.spec.ts b/packages/node-lib/src/__tests__/jpeg-library.spec.ts similarity index 100% rename from packages/node/src/__tests__/jpeg-library.spec.ts rename to packages/node-lib/src/__tests__/jpeg-library.spec.ts diff --git a/packages/node/src/jpeg.ts b/packages/node-lib/src/jpeg.ts similarity index 100% rename from packages/node/src/jpeg.ts rename to packages/node-lib/src/jpeg.ts diff --git a/packages/node-lib/src/lib.ts b/packages/node-lib/src/lib.ts new file mode 100644 index 0000000..1a099fc --- /dev/null +++ b/packages/node-lib/src/lib.ts @@ -0,0 +1 @@ +export * from './jpeg.js' diff --git a/packages/node-lib/tsconfig.build.json b/packages/node-lib/tsconfig.build.json new file mode 100644 index 0000000..88a58b5 --- /dev/null +++ b/packages/node-lib/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "exclude": ["node_modules/**", "src/**/*spec.ts", "src/**/__tests__/*", "src/**/__mocks__/*"], + "include": ["src/**/*"], + "compilerOptions": { + "outDir": "./dist", + "baseUrl": "./", + "paths": { + "*": ["./node_modules/*"], + "@elgato-stream-deck/node-lib": ["./src/index.ts"] + }, + "types": ["node"] + } +} diff --git a/packages/node-lib/tsconfig.json b/packages/node-lib/tsconfig.json new file mode 100644 index 0000000..39cf967 --- /dev/null +++ b/packages/node-lib/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.build.json", + "exclude": ["node_modules/**"], + "compilerOptions": { + "types": ["jest", "node"] + } +} diff --git a/packages/node/examples/streamdeck-plus.js b/packages/node/examples/streamdeck-plus.js index 713b463..d509d7d 100644 --- a/packages/node/examples/streamdeck-plus.js +++ b/packages/node/examples/streamdeck-plus.js @@ -5,9 +5,10 @@ const { listStreamDecks, openStreamDeck, DeviceModelId } = require('../dist/inde ;(async () => { const devices = await listStreamDecks() - if (!devices[0]) throw new Error('No device found') + const plusDevice = devices.find((dev) => dev.model === DeviceModelId.PLUS) + if (!plusDevice) throw new Error('No device found') - const streamDeck = await openStreamDeck(devices[0].path) + const streamDeck = await openStreamDeck(plusDevice.path) await streamDeck.clearPanel() if (streamDeck.MODEL !== DeviceModelId.PLUS) throw new Error('This demo only supports the plus') @@ -55,14 +56,14 @@ const { listStreamDecks, openStreamDeck, DeviceModelId } = require('../dist/inde streamDeck.on('rotate', (control, amount) => { console.log('Encoder rotate #%d (%d)', control.index, amount) }) - streamDeck.on('lcdShortPress', (index, pos) => { - console.log('lcd short press #%d (%d, %d)', index, pos.x, pos.y) + streamDeck.on('lcdShortPress', (control, pos) => { + console.log('lcd short press #%d (%d, %d)', control.id, pos.x, pos.y) }) - streamDeck.on('lcdLongPress', (index, pos) => { - console.log('lcd long press #%d (%d, %d)', index, pos.x, pos.y) + streamDeck.on('lcdLongPress', (control, pos) => { + console.log('lcd long press #%d (%d, %d)', control.id, pos.x, pos.y) }) - streamDeck.on('lcdSwipe', (index, index2, pos, pos2) => { - console.log('lcd swipe #%d->#%d (%d, %d)->(%d, %d)', index, index2, pos.x, pos.y, pos2.x, pos2.y) + streamDeck.on('lcdSwipe', (control, from, to) => { + console.log('lcd swipe #%d (%d, %d)->(%d, %d)', control.id, from.x, from.y, to.x, to.y) }) streamDeck.on('error', (error) => { diff --git a/packages/node/examples/streamdeck-studio.js b/packages/node/examples/streamdeck-studio.js new file mode 100644 index 0000000..245740a --- /dev/null +++ b/packages/node/examples/streamdeck-studio.js @@ -0,0 +1,101 @@ +// @ts-check +const path = require('path') +const sharp = require('sharp') +const { listStreamDecks, openStreamDeck, DeviceModelId } = require('../dist/index') + +function generateEncoderColor(value, max) { + const colors = Buffer.alloc(max * 3) + + for (let i = 0; i < max; i++) { + const color = i < value ? 255 : 0 + colors[i * 3] = color + colors[i * 3 + 1] = color + colors[i * 3 + 2] = color + } + + return colors +} + +;(async () => { + const devices = await listStreamDecks() + const studioDevice = devices.find((dev) => dev.model === DeviceModelId.STUDIO) + if (!studioDevice) throw new Error('No device found') + + const streamDeck = await openStreamDeck(studioDevice.path) + await streamDeck.clearPanel() + + if (streamDeck.MODEL !== DeviceModelId.STUDIO) throw new Error('This demo only supports the studio') + + console.log('firmware', await streamDeck.getFirmwareVersion()) + console.log('serial number', await streamDeck.getSerialNumber()) + + /** @type {import('@elgato-stream-deck/core').StreamDeckEncoderControlDefinition[]} */ + const encoders = streamDeck.CONTROLS.filter((control) => control.type === 'encoder') + + const encoderValues = encoders.map((encoder) => Math.round(encoder.ledRingSteps / 2)) + for (const control of encoders) { + streamDeck + .setEncoderRingColors( + control.index, + generateEncoderColor(encoderValues[control.index], control.ledRingSteps), + ) + .catch((e) => console.error('Fill failed:', e)) + } + + const img = await sharp(path.resolve(__dirname, 'fixtures/github_logo.png')) + .flatten() + .resize(streamDeck.BUTTON_WIDTH_PX, streamDeck.BUTTON_HEIGHT_PX) + .raw() + .toBuffer() + + streamDeck.on('nfcRead', (id) => { + console.log('nfc read', id, id.length) + }) + + streamDeck.on('down', (control) => { + if (control.type === 'button') { + // Fill the pressed key with an image of the GitHub logo. + console.log('Filling button #%d', control.index) + if (control.feedbackType === 'lcd') { + streamDeck.fillKeyBuffer(control.index, img).catch((e) => console.error('Fill failed:', e)) + } else { + streamDeck.fillKeyColor(control.index, 255, 255, 255).catch((e) => console.error('Fill failed:', e)) + } + } else { + console.log('Filling encoder #%d', control.index) + + streamDeck.setEncoderColor(control.index, 255, 0, 0).catch((e) => console.error('Fill failed:', e)) + } + }) + + streamDeck.on('up', (control) => { + if (control.type === 'button') { + // Clear the key when it is released. + console.log('Clearing button #%d', control.index) + streamDeck.clearKey(control.index).catch((e) => console.error('Clear failed:', e)) + } else { + console.log('Clearing encoder #%d', control.index) + + streamDeck.setEncoderColor(control.index, 0, 0, 0).catch((e) => console.error('Fill failed:', e)) + } + }) + + streamDeck.on('rotate', (control, amount) => { + console.log('Encoder rotate #%d %d', control.index, amount) + + encoderValues[control.index] = Math.min( + control.ledRingSteps, + Math.max(0, encoderValues[control.index] + amount), + ) + streamDeck + .setEncoderRingColors( + control.index, + generateEncoderColor(encoderValues[control.index], control.ledRingSteps), + ) + .catch((e) => console.error('Fill failed:', e)) + }) + + streamDeck.on('error', (error) => { + console.error(error) + }) +})() diff --git a/packages/node/package.json b/packages/node/package.json index 6921de4..a0898f4 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -53,12 +53,9 @@ }, "dependencies": { "@elgato-stream-deck/core": "7.0.0-0", + "@elgato-stream-deck/node-lib": "7.0.0-0", "eventemitter3": "^5.0.1", - "jpeg-js": "^0.4.4", "node-hid": "^3.1.0", "tslib": "^2.7.0" - }, - "peerDependencies": { - "@julusian/jpeg-turbo": "^1.1.2 || ^2.0.0" } } diff --git a/packages/node/src/hid-device.ts b/packages/node/src/hid-device.ts index c685f6b..6350ed6 100644 --- a/packages/node/src/hid-device.ts +++ b/packages/node/src/hid-device.ts @@ -1,5 +1,6 @@ import type { DeviceModelId, HIDDevice, HIDDeviceEvents, HIDDeviceInfo } from '@elgato-stream-deck/core' -import * as EventEmitter from 'eventemitter3' +import type { ChildHIDDeviceInfo } from '@elgato-stream-deck/core/dist/hid-device' +import { EventEmitter } from 'eventemitter3' import type { HIDAsync, Device as NodeHIDDeviceInfo } from 'node-hid' /** @@ -63,4 +64,9 @@ export class NodeHIDDevice extends EventEmitter implements HIDD vendorId: info.vendorId, } } + + public async getChildDeviceInfo(): Promise { + // Not supported + return null + } } diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 8394ab9..e294167 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -2,8 +2,8 @@ import type { OpenStreamDeckOptions, StreamDeck } from '@elgato-stream-deck/core import { DEVICE_MODELS, VENDOR_ID } from '@elgato-stream-deck/core' import * as HID from 'node-hid' import { NodeHIDDevice, StreamDeckDeviceInfo } from './hid-device.js' -import { encodeJPEG, JPEGEncodeOptions } from './jpeg.js' import { StreamDeckNode } from './wrapper.js' +import { encodeJPEG, JPEGEncodeOptions } from '@elgato-stream-deck/node-lib' export { VENDOR_ID, diff --git a/packages/node/udev/50-elgato-stream-deck-headless.rules b/packages/node/udev/50-elgato-stream-deck-headless.rules index 0837814..d6d31d7 100644 --- a/packages/node/udev/50-elgato-stream-deck-headless.rules +++ b/packages/node/udev/50-elgato-stream-deck-headless.rules @@ -1,19 +1,21 @@ -SUBSYSTEM=="input", GROUP="input", MODE="0660" -SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", MODE:="660", GROUP="plugdev" -SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", MODE:="660", GROUP="plugdev" -SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", MODE:="660", GROUP="plugdev" -SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006d", MODE:="660", GROUP="plugdev" -SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0080", MODE:="660", GROUP="plugdev" -SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0084", MODE:="660", GROUP="plugdev" -SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE:="660", GROUP="plugdev" -SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE:="660", GROUP="plugdev" -SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="009A", MODE:="660", GROUP="plugdev" -KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", MODE:="660", GROUP="plugdev" -KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", MODE:="660", GROUP="plugdev" -KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", MODE:="660", GROUP="plugdev" -KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006d", MODE:="660", GROUP="plugdev" -KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0080", MODE:="660", GROUP="plugdev" -KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0084", MODE:="660", GROUP="plugdev" -KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE:="660", GROUP="plugdev" -KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE:="660", GROUP="plugdev" -KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="009A", MODE:="660", GROUP="plugdev" \ No newline at end of file +SUBSYSTEM=="input", GROUP="input", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", MODE="660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", MODE="660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", MODE="660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006d", MODE="660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0080", MODE="660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0084", MODE="660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE="660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE="660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="009A", MODE="660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00AA", MODE="660", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", MODE="660", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", MODE="660", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", MODE="660", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006d", MODE="660", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0080", MODE="660", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0084", MODE="660", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE="660", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE="660", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="009A", MODE="660", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00AA", MODE="660", TAG+="uaccess" diff --git a/packages/node/udev/50-elgato-stream-deck-user.rules b/packages/node/udev/50-elgato-stream-deck-user.rules index 5661390..d6d31d7 100644 --- a/packages/node/udev/50-elgato-stream-deck-user.rules +++ b/packages/node/udev/50-elgato-stream-deck-user.rules @@ -8,6 +8,7 @@ SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0084", MODE="660", SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE="660", TAG+="uaccess" SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE="660", TAG+="uaccess" SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="009A", MODE="660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00AA", MODE="660", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", MODE="660", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", MODE="660", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", MODE="660", TAG+="uaccess" @@ -17,3 +18,4 @@ KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0084", MODE="660" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE="660", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE="660", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="009A", MODE="660", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00AA", MODE="660", TAG+="uaccess" diff --git a/packages/tcp/CHANGELOG.md b/packages/tcp/CHANGELOG.md new file mode 100644 index 0000000..e4d87c4 --- /dev/null +++ b/packages/tcp/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. diff --git a/packages/tcp/LICENSE b/packages/tcp/LICENSE new file mode 100644 index 0000000..ebc5513 --- /dev/null +++ b/packages/tcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Julian Waller + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/tcp/README.md b/packages/tcp/README.md new file mode 100644 index 0000000..f7b06a4 --- /dev/null +++ b/packages/tcp/README.md @@ -0,0 +1,78 @@ +# @elgato-stream-deck/tcp + +![Node CI](https://github.com/Julusian/node-elgato-stream-deck/workflows/Node%20CI/badge.svg) +[![codecov](https://codecov.io/gh/Julusian/node-elgato-stream-deck/branch/master/graph/badge.svg?token=Hl4QXGZJMF)](https://codecov.io/gh/Julusian/node-elgato-stream-deck) + +[![npm version](https://img.shields.io/npm/v/@elgato-stream-deck/tcp.svg)](https://npm.im/@elgato-stream-deck/tcp) +[![license](https://img.shields.io/npm/l/@elgato-stream-deck/tcp.svg)](https://npm.im/@elgato-stream-deck/tcp) + +[`@elgato-stream-deck/tcp`](https://github.com/julusian/node-elgato-stream-deck) is a shared library for interfacing +with the various models of the [Elgato Stream Deck](https://www.elgato.com/en/gaming/stream-deck). + +## Intended use + +This library has nothing to do with the streamdeck software produced by Elgato. There is nothing here to install and run. This is a library to help developers make alternatives to that software + +## Install + +`$ npm install --save @elgato-stream-deck/tcp` + +`$ npm install --save @julusian/jpeg-turbo@^2.0.0` (Optional) + +It is recommended to install `@julusian/jpeg-turbo` to greatly improve performance for writing images to the StreamDeck XL or the Original-v2. Without doing so `jpeg-js` will be used instead, but image transfers will be noticably more cpu intensive and slower. `jpeg-turbo` has prebuilt binaries, but is not installed by default to ensure installation is easy for users who do not need the performance for the XL or the Original-v2. + +### Native dependencies + +All of this library's native dependencies ship with prebuilt binaries, so having a full compiler toolchain should not be necessary to install `@elgato-stream-deck/tcp`. + +However, in the event that installation _does_ fail (**or if you are on a platform that our dependencies don't provide prebuilt binaries for, such as a Raspberry Pi**), you will need to install a compiler toolchain to enable npm to build some of `@elgato-stream-deck/tcp`'s dependencies from source. Expand the details block below for full instructions on how to do so. + +
+ Compiling dependencies from source + +* Windows + * Install [`windows-build-tools`](https://github.com/felixrieseberg/windows-build-tools): + ```bash + npm install --global windows-build-tools + ``` +* MacOS + * Install the Xcode Command Line Tools: + ```bash + xcode-select --install + ``` +* Linux (**including Raspberry Pi**) + * Follow the instructions for Linux in the ["Compiling from source"](https://github.com/node-hid/node-hid#compiling-from-source) steps for + * Try installing `@elgato-stream-deck/tcp` + * If you still have issues, ensure everything is updated and try again: + ```bash + sudo apt-get update && sudo apt-get upgrade + ``` +
+ +## Features + +TODO + +## API + +The root methods exposed by the library are as follows. For more information it is recommended to rely on the typescript typings for hints or to browse through the source to see what methods are available + +```typescript +// TODO +``` + +The StreamDeck type can be found [here](/packages/core/src/models/types.ts#L15) + +## Example + +```typescript +// TODO +``` + +Some more complex demos can be found in the [examples](examples/) folder. + +## Contributing + +The elgato-stream-deck team enthusiastically welcomes contributions and project participation! There's a bunch of things you can do if you want to contribute! Please don't hesitate to jump in if you'd like to, or even ask us questions if something isn't clear. + +Please refer to the [Changelog](CHANGELOG.md) for project history details, too. diff --git a/packages/tcp/examples/discovery.mjs b/packages/tcp/examples/discovery.mjs new file mode 100644 index 0000000..04d34bf --- /dev/null +++ b/packages/tcp/examples/discovery.mjs @@ -0,0 +1,13 @@ +import { StreamDeckTcpDiscoveryService } from '../dist/index.js' + +const discover = new StreamDeckTcpDiscoveryService() +discover.on('up', (service) => { + console.log('up', service) +}) +discover.on('down', (service) => { + console.log('down', service) +}) + +setInterval(() => { + // discover.query() +}, 5000) diff --git a/packages/tcp/examples/fixtures/github_logo.png b/packages/tcp/examples/fixtures/github_logo.png new file mode 100644 index 0000000..92d3c95 Binary files /dev/null and b/packages/tcp/examples/fixtures/github_logo.png differ diff --git a/packages/tcp/examples/streamdeck-tcp.mjs b/packages/tcp/examples/streamdeck-tcp.mjs new file mode 100644 index 0000000..69c6d23 --- /dev/null +++ b/packages/tcp/examples/streamdeck-tcp.mjs @@ -0,0 +1,158 @@ +// @ts-check +import path from 'path' +import sharp from 'sharp' +import { StreamDeckTcpConnectionManager } from '../dist/index.js' + +const connectionManager = new StreamDeckTcpConnectionManager() + +function generateEncoderColor(value, max) { + const colors = Buffer.alloc(max * 3) + + for (let i = 0; i < max; i++) { + const color = i < value ? 255 : 0 + colors[i * 3] = color + colors[i * 3 + 1] = color + colors[i * 3 + 2] = color + } + + return colors +} + +connectionManager.connectTo('10.42.13.166') + +connectionManager.on('error', (err) => { + console.log('error', err) +}) + +connectionManager.on('connected', async (streamDeck) => { + streamDeck.tcpEvents.on('disconnected', () => { + console.log('disconnect') + }) + streamDeck.on('error', (err) => { + console.log('sd error', err) + }) + console.log('connected!') + + const img = await sharp(path.resolve('fixtures/github_logo.png')) + .flatten() + .resize(streamDeck.BUTTON_WIDTH_PX, streamDeck.BUTTON_HEIGHT_PX) + .raw() + .toBuffer() + + const fullSize = streamDeck.calculateFillPanelDimensions() + const fullImg = + fullSize && + (await sharp(path.resolve('fixtures/github_logo.png')) + .flatten() + .resize(fullSize.width, fullSize.height, { fit: 'fill' }) + .raw() + .toBuffer()) + + if (fullImg) { + console.log('send fill') + streamDeck.fillPanelBuffer(fullImg).catch((e) => console.log('fullImg failed', e)) + + console.log('post send fill') + } + + streamDeck + .setBrightness(80) + .then(() => console.log('brightness ok')) + .catch((e) => console.log('brightness failed', e)) + + streamDeck + .getSerialNumber() + .then((serial) => console.log('serial', serial)) + .catch((e) => console.log('serial failed', e)) + + streamDeck + .getFirmwareVersion() + .then((version) => console.log('firmware', version)) + .catch((e) => console.log('firmware failed', e)) + + streamDeck + .getMacAddress() + .then((version) => console.log('mac address', version)) + .catch((e) => console.log('mac address failed', e)) + + streamDeck + .getHidDeviceInfo() + .then((info) => console.log('hid info', info)) + .catch((e) => console.log('hid info failed', e)) + + streamDeck.clearPanel().catch((e) => console.log('clear faild', e)) + + streamDeck + .getChildDeviceInfo() + .then((info) => console.log('child info', info)) + .catch((e) => console.log('child info failed', e)) + + /** @type {import('@elgato-stream-deck/core').StreamDeckEncoderControlDefinition[]} */ + const encoders = streamDeck.CONTROLS.filter((control) => control.type === 'encoder') + + const encoderValues = encoders.map((encoder) => Math.round(encoder.ledRingSteps / 2)) + + for (const control of encoders) { + if (control.ledRingSteps > 0) { + streamDeck + .setEncoderRingColors( + control.index, + generateEncoderColor(encoderValues[control.index], control.ledRingSteps), + ) + .catch((e) => console.error('Fill failed:', e)) + } + } + + // setTimeout(() => { + // streamDeck.resetToLogo() + // }, 2000) + + streamDeck.on('disconnected', () => { + console.log('disconnected!') + }) + + streamDeck.on('down', (control) => { + if (control.type === 'button') { + // Fill the pressed key with an image of the GitHub logo. + console.log('Filling button #%d', control.index) + if (control.feedbackType === 'lcd') { + streamDeck.fillKeyBuffer(control.index, img).catch((e) => console.error('Fill failed:', e)) + } else { + streamDeck.fillKeyColor(control.index, 255, 255, 255).catch((e) => console.error('Fill failed:', e)) + } + } else if (control.hasLed) { + console.log('Filling encoder #%d', control.index) + + streamDeck.setEncoderColor(control.index, 255, 0, 0).catch((e) => console.error('Fill failed:', e)) + } + }) + streamDeck.on('up', (control) => { + if (control.type === 'button') { + // Clear the key when it is released. + console.log('Clearing button #%d', control.index) + streamDeck.clearKey(control.index).catch((e) => console.error('Clear failed:', e)) + } else if (control.hasLed) { + console.log('Clearing encoder #%d', control.index) + + streamDeck.setEncoderColor(control.index, 0, 0, 0).catch((e) => console.error('Fill failed:', e)) + } + }) + streamDeck.on('rotate', (control, amount) => { + console.log('rotate', control, amount) + + encoderValues[control.index] = Math.min( + control.ledRingSteps, + Math.max(0, encoderValues[control.index] + amount), + ) + + if (control.ledRingSteps > 0) { + streamDeck + .setEncoderRingColors( + control.index, + generateEncoderColor(encoderValues[control.index], control.ledRingSteps), + ) + .catch((e) => console.error('Fill failed:', e)) + } + }) + streamDeck.on('nfcRead', (id) => console.log('nfc read', id)) +}) diff --git a/packages/tcp/package.json b/packages/tcp/package.json new file mode 100644 index 0000000..3c0b918 --- /dev/null +++ b/packages/tcp/package.json @@ -0,0 +1,45 @@ +{ + "name": "@elgato-stream-deck/tcp", + "version": "7.0.0-0", + "description": "An npm module for interfacing with select Elgato Stream Deck devices in node over tcp", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "license": "MIT", + "homepage": "https://github.com/julusian/node-elgato-stream-deck#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/julusian/node-elgato-stream-deck.git" + }, + "bugs": { + "url": "https://github.com/julusian/node-elgato-stream-deck/issues" + }, + "author": { + "name": "Julian Waller", + "email": "git@julusian.co.uk" + }, + "keywords": [ + "elgato", + "stream", + "deck", + "streamdeck", + "hid", + "usb", + "hardware", + "interface", + "controller" + ], + "files": [ + "dist", + "udev" + ], + "engines": { + "node": ">=18.18" + }, + "dependencies": { + "@elgato-stream-deck/core": "7.0.0-0", + "@elgato-stream-deck/node-lib": "7.0.0-0", + "@julusian/bonjour-service": "^1.3.0-2", + "eventemitter3": "^5.0.1", + "tslib": "^2.6.3" + } +} diff --git a/packages/tcp/src/__tests__/helpers.ts b/packages/tcp/src/__tests__/helpers.ts new file mode 100644 index 0000000..cec1c24 --- /dev/null +++ b/packages/tcp/src/__tests__/helpers.ts @@ -0,0 +1,8 @@ +import * as fs from 'fs' +import * as path from 'path' + +export function readFixtureJSON(fileName: string): Buffer { + const filePath = path.resolve(__dirname, '../../../../fixtures', fileName) + const fileData = fs.readFileSync(filePath) + return Buffer.from(JSON.parse(fileData.toString()) as Array) +} diff --git a/packages/tcp/src/connectionManager.ts b/packages/tcp/src/connectionManager.ts new file mode 100644 index 0000000..17030ff --- /dev/null +++ b/packages/tcp/src/connectionManager.ts @@ -0,0 +1,284 @@ +import * as EventEmitter from 'events' +import type { OpenStreamDeckOptionsTcp, StreamDeckTcp } from './types.js' +import { DEFAULT_TCP_PORT } from './constants.js' +import { SocketWrapper } from './socketWrapper.js' +import { type JPEGEncodeOptions, encodeJPEG } from '@elgato-stream-deck/node-lib' +import type { HIDDevice, OpenStreamDeckOptions, ChildHIDDeviceInfo, PropertiesService } from '@elgato-stream-deck/core' +import { DEVICE_MODELS } from '@elgato-stream-deck/core' +import { StreamDeckTcpWrapper } from './tcpWrapper.js' +import { TcpHidDevice } from './hid-device.js' + +export interface StreamDeckTcpConnectionManagerEvents { + connected: [streamdeck: StreamDeckTcp] + disconnected: [streamdeck: StreamDeckTcp] + error: [message: string] +} + +/** For future use */ +export type StreamDeckTcpConnectionOptions = Record + +export interface SocketAndInfo { + readonly socket: SocketWrapper + childId: string | null +} + +export class StreamDeckTcpConnectionManager extends EventEmitter { + readonly #connections = new Map() + readonly #streamdecks = new Map() + + readonly #openOptions: Required + readonly #autoConnectToSecondaries: boolean + + #timeoutInterval: NodeJS.Timeout | null = null + + constructor(userOptions?: OpenStreamDeckOptionsTcp) { + super() + + // Clone the options, to ensure they dont get changed + const jpegOptions: JPEGEncodeOptions | undefined = userOptions?.jpegOptions + ? { ...userOptions.jpegOptions } + : undefined + + this.#openOptions = { + encodeJPEG: async (buffer: Uint8Array, width: number, height: number) => + encodeJPEG(buffer, width, height, jpegOptions), + ...userOptions, + } + this.#autoConnectToSecondaries = userOptions?.autoConnectToSecondaries ?? true + } + + #getConnectionId(address: string, port: number) { + return `${address}:${port || DEFAULT_TCP_PORT}` + } + + #onSocketConnected = (socket: SocketWrapper) => { + const connectionId = this.#getConnectionId(socket.address, socket.port) + + // TODO - error handling? + const fakeHidDevice = new TcpHidDevice(socket) + + fakeHidDevice + .getDeviceInfo() + .then((info) => { + const model = DEVICE_MODELS.find((m) => m.productIds.includes(info.productId)) + if (!model) { + this.emit('error', `Found StreamDeck with unknown productId: ${info.productId.toString(16)}`) + return + } + + const propertiesService = fakeHidDevice.isPrimary ? new TcpPropertiesService(fakeHidDevice) : undefined + const streamdeckSocket = model.factory(fakeHidDevice, this.#openOptions, propertiesService) + const streamDeckTcp = new StreamDeckTcpWrapper(socket, fakeHidDevice, streamdeckSocket) + + this.#streamdecks.set(connectionId, streamDeckTcp) + + setImmediate(() => this.emit('connected', streamDeckTcp)) + + if (this.#autoConnectToSecondaries && fakeHidDevice.isPrimary) { + this.#tryConnectingToSecondary(connectionId, socket, streamDeckTcp) + } + }) + .catch((err) => { + this.emit('error', `Failed to open device ${connectionId}: ${err}`) + }) + } + + #tryConnectingToSecondary(parentId: string, _parentSocket: SocketWrapper, parent: StreamDeckTcpWrapper) { + const connectToUpdatedChildInfo = (info: ChildHIDDeviceInfo | null) => { + // Check the current parent is still active + const currentParent = this.#streamdecks.get(parentId) + if (currentParent !== parent) return + + // Get the parent socket info, this should always exist + const parentSocketInfo = this.#connections.get(parentId) + if (!parentSocketInfo) return + if (!info) { + // Child disconnected + if (parentSocketInfo.childId) { + this.#disconnectFromId(parentSocketInfo.childId) + parentSocketInfo.childId = null + } + } else { + const childId = this.#getConnectionId(parent.remoteAddress, info.tcpPort) + if (childId === parentId) return // Shouldn't happen, but could cause an infinite loop + + if (parentSocketInfo.childId !== childId || !this.#connections.has(childId)) { + // Make sure an existing child is disposed + if (parentSocketInfo.childId) { + this.#disconnectFromId(parentSocketInfo.childId) + } + + // Start connecting to the new child + parentSocketInfo.childId = childId + this.#connectToInternal(parent.remoteAddress, info.tcpPort, {}) + } + } + } + + // Setup watching hotplug events + parent.tcpEvents.on('childChange', (info) => connectToUpdatedChildInfo(info)) + + // Do a check now, to see what is connected + parent + .getChildDeviceInfo() + .then((childInfo) => connectToUpdatedChildInfo(childInfo)) + .catch(() => { + // TODO - log + }) + } + + #onSocketDisconnected = (socket: SocketWrapper) => { + const id = this.#getConnectionId(socket.address, socket.port) + + // Clear and re-add all listeners, to ensure we don't leak anything + socket.removeAllListeners() + this.#setupSocketEventHandlers(socket) + + const streamdeck = this.#streamdecks.get(id) + if (streamdeck) { + this.#streamdecks.delete(id) + + setImmediate(() => this.emit('disconnected', streamdeck)) + } + } + + #startTimeoutInterval() { + if (this.#timeoutInterval) return + + this.#timeoutInterval = setInterval(() => { + for (const entry of this.#connections.values()) { + entry.socket.checkForTimeout() + } + }, 1000) + } + + #stopTimeoutInterval() { + if (!this.#timeoutInterval) return + if (this.#connections.size > 0) return + + clearInterval(this.#timeoutInterval) + this.#timeoutInterval = null + } + + connectTo( + address: string, + port: number = DEFAULT_TCP_PORT, + options?: Partial, + ): void { + if (!this.#connectToInternal(address, port, options)) { + throw new Error('Connection already exists') + } + } + + #connectToInternal( + address: string, + port: number, + _options: Partial | undefined, + ): boolean { + const id = this.#getConnectionId(address, port) + + if (this.#connections.has(id)) return false + + const newSocket = new SocketWrapper(address, port) + this.#setupSocketEventHandlers(newSocket) + this.#connections.set(id, { socket: newSocket, childId: null }) + + this.#startTimeoutInterval() + + return true + } + + #setupSocketEventHandlers(socket: SocketWrapper) { + socket.on('connected', () => this.#onSocketConnected(socket)) + socket.on('disconnected', () => this.#onSocketDisconnected(socket)) + socket.on('error', () => { + // TODO + }) + } + + disconnectFrom(address: string, port: number = DEFAULT_TCP_PORT): boolean { + const id = this.#getConnectionId(address, port) + + return this.#disconnectFromId(id) + } + + #disconnectFromId(id: string): boolean { + const entry = this.#connections.get(id) + if (!entry) return false + + // Disconnect from child if it is known + if (entry.childId) { + this.#disconnectFromId(entry.childId) + } + + this.#connections.delete(id) + + entry.socket.close().catch(() => { + // TODO - log + }) + + this.#stopTimeoutInterval() + + return true + } + + disconnectFromAll(): void { + for (const socket of this.#connections.values()) { + socket.socket.close().catch(() => { + // TODO - log + }) + } + + this.#connections.clear() + } + + getStreamdeckFor(address: string, port: number = DEFAULT_TCP_PORT): StreamDeckTcp | undefined { + return this.#streamdecks.get(this.#getConnectionId(address, port)) + } +} + +class TcpPropertiesService implements PropertiesService { + readonly #device: HIDDevice + + constructor(device: HIDDevice) { + this.#device = device + } + + public async setBrightness(percentage: number): Promise { + if (percentage < 0 || percentage > 100) { + throw new RangeError('Expected brightness percentage to be between 0 and 100') + } + + const buffer = Buffer.alloc(1024) + buffer.writeUint8(0x03, 0) + buffer.writeUint8(0x08, 1) + buffer.writeUint8(percentage, 2) + + await this.#device.sendFeatureReport(buffer) + } + + public async resetToLogo(): Promise { + throw new Error('Not implemented') + // TODO the soft reset below is too much, needs something lighter + + // const buffer = Buffer.alloc(1024) + // buffer.writeUint8(0x03, 0) + // buffer.writeUint8(0x0b, 1) + // buffer.writeUint8(0, 2) // Soft Reset + + // await this.#sendMessages([buffer]) + } + + public async getFirmwareVersion(): Promise { + const data = await this.#device.getFeatureReport(0x83, -1) + + return new TextDecoder('ascii').decode(data.subarray(8, 16)) + } + + public async getSerialNumber(): Promise { + const data = await this.#device.getFeatureReport(0x84, -1) + + const length = data[3] + return new TextDecoder('ascii').decode(data.subarray(4, 4 + length)) + } +} diff --git a/packages/tcp/src/constants.ts b/packages/tcp/src/constants.ts new file mode 100644 index 0000000..23eb45e --- /dev/null +++ b/packages/tcp/src/constants.ts @@ -0,0 +1,6 @@ +export const DEFAULT_TCP_PORT = 5343 + +export const RECONNECT_INTERVAL = 5000 +export const TIMEOUT_DURATION = 5000 // Note: must be more than the + +export const DEFAULT_MDNS_QUERY_INTERVAL = 10000 diff --git a/packages/tcp/src/device2Info.ts b/packages/tcp/src/device2Info.ts new file mode 100644 index 0000000..9cb5592 --- /dev/null +++ b/packages/tcp/src/device2Info.ts @@ -0,0 +1,34 @@ +import { uint8ArrayToDataView, type StreamDeckTcpChildDeviceInfo } from '@elgato-stream-deck/core' + +export function parseDevice2Info(device2Info: Uint8Array): Omit | null { + if (device2Info[4] !== 0x02) { + // Nothing connected, or not OK + return null + } + + const dataView = uint8ArrayToDataView(device2Info) + + const vendorId = dataView.getUint16(26, true) + const productId = dataView.getUint16(28, true) + + const serialNumberStart = 94 + const serialNumberEnd = 125 + const firstNullInSerial = device2Info.subarray(serialNumberStart, serialNumberEnd).indexOf(0x00) + const serialNumber = new TextDecoder('ascii').decode( + device2Info.subarray( + serialNumberStart, + firstNullInSerial > -1 ? serialNumberStart + firstNullInSerial : serialNumberEnd, + ), + ) + + const tcpPort = dataView.getUint16(126, true) + + return { + serialNumber, + tcpPort, + + vendorId, + productId, + path: undefined, + } +} diff --git a/packages/tcp/src/discoveryService.ts b/packages/tcp/src/discoveryService.ts new file mode 100644 index 0000000..eef0029 --- /dev/null +++ b/packages/tcp/src/discoveryService.ts @@ -0,0 +1,132 @@ +import type { Browser, Service as BonjourService } from '@julusian/bonjour-service' +import { Bonjour } from '@julusian/bonjour-service' +import * as EventEmitter from 'events' +import { DEFAULT_MDNS_QUERY_INTERVAL } from './constants.js' +import type { DeviceModelId, DeviceModelType } from '@elgato-stream-deck/core' +import { DEVICE_MODELS, VENDOR_ID } from '@elgato-stream-deck/core' + +export interface StreamDeckTcpDiscoveryServiceOptions { + /** + * How often to update the mDNS query, in milliseconds. + * Set to 0 to disable, when calling query() manually. + * Note: this must not be higher than the ttl reported by the streamdecks, which is currently 60s + */ + queryInterval?: number +} + +export interface StreamDeckTcpDefinition { + address: string + port: number + name: string + + vendorId: number + productId: number + + serialNumber?: string + + modelType: DeviceModelType + modelId: DeviceModelId + modelName: string + + /** + * Whether this is a primary tcp device, or using a secondary usb port on a tcp device + */ + isPrimary: boolean +} + +function convertService(service: BonjourService): StreamDeckTcpDefinition | null { + if (!service.addresses || service.addresses.length === 0) return null + + // Get and parse the vendor and product id + const vendorId = Number(service.txt.vid) + const productId = Number(service.txt.pid) + if (isNaN(vendorId) || isNaN(productId)) return null + + // Find the corresponding model + const model = DEVICE_MODELS.find((model) => VENDOR_ID === vendorId && model.productIds.includes(productId)) + if (!model) return null + + return { + address: service.addresses[0], + port: service.port, + name: service.name, + + vendorId, + productId, + + serialNumber: service.txt.sn, + + modelType: model.type, + modelId: model.id, + modelName: model.productName, + + isPrimary: model.hasNativeTcp, + } +} + +export interface StreamDeckTcpDiscoveryServiceEvents { + up: [service: StreamDeckTcpDefinition] + down: [service: StreamDeckTcpDefinition] +} + +export class StreamDeckTcpDiscoveryService extends EventEmitter { + readonly #server: Bonjour + + readonly #browser: Browser + readonly #queryInterval: NodeJS.Timeout | undefined + + constructor(options?: StreamDeckTcpDiscoveryServiceOptions) { + super() + + this.#server = new Bonjour() + + this.#browser = this.#server.find({ + type: 'elg', + protocol: 'tcp', + }) + + this.#browser.on('up', (service) => this.#emitUp(service)) + this.#browser.on('down', (service) => this.#emitDown(service)) + this.#browser.on('srv-update', (newService, existingService) => { + this.#emitDown(existingService) + this.#emitUp(newService) + }) + + const queryInterval = options?.queryInterval ?? DEFAULT_MDNS_QUERY_INTERVAL + if (queryInterval >= 0) { + this.#queryInterval = setInterval(() => this.query(), queryInterval) + } + } + + get knownStreamDecks(): StreamDeckTcpDefinition[] { + return this.#browser.services.map(convertService).filter((svc): svc is StreamDeckTcpDefinition => !!svc) + } + + #emitDown(service: BonjourService) { + const serviceDefinition = convertService(service) + if (!serviceDefinition) return + this.emit('down', serviceDefinition) + } + #emitUp(service: BonjourService) { + const serviceDefinition = convertService(service) + if (!serviceDefinition) return + this.emit('up', serviceDefinition) + } + + /** + * Broadcast the query to the network + */ + query(): void { + // Tell the browser to resend the query + this.#browser.update() + + // Tell the browser to expire any services that haven't been seen in a while + this.#browser.expire() + } + + destroy(): void { + if (this.#queryInterval) clearInterval(this.#queryInterval) + + this.#server.destroy() + } +} diff --git a/packages/tcp/src/hid-device.ts b/packages/tcp/src/hid-device.ts new file mode 100644 index 0000000..2e0ef2e --- /dev/null +++ b/packages/tcp/src/hid-device.ts @@ -0,0 +1,202 @@ +import * as EventEmitter from 'events' +import { + type HIDDeviceInfo, + type HIDDevice, + type HIDDeviceEvents, + type ChildHIDDeviceInfo, + type StreamDeckTcpChildDeviceInfo, + uint8ArrayToDataView, +} from '@elgato-stream-deck/core' +import type { SocketWrapper } from './socketWrapper.js' +import { parseDevice2Info } from './device2Info.js' + +class QueuedCommand { + public readonly promise: Promise + public readonly commandType: number + + constructor(commandType: number) { + this.commandType = commandType + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve + this.reject = reject + }) + } + + resolve(_res: Buffer) { + throw new Error('No promise to resolve') + } + + reject(_res: any) { + throw new Error('No promise to reject') + } +} + +/** + * A HIDDevice implementation for TCP connections + * This isn't really HID, but it fits the existing structure well enough + */ +export class TcpHidDevice extends EventEmitter implements HIDDevice { + readonly #socket: SocketWrapper + #isPrimary = true + #onChildInfoChange: ((info: Omit | null) => void) | null = null + + get isPrimary(): boolean { + return this.#isPrimary + } + + set onChildInfoChange(cb: ((info: Omit | null) => void) | null) { + this.#onChildInfoChange = cb + } + + constructor(socket: SocketWrapper) { + super() + + this.#socket = socket + + this.#socket.on('data', (data) => { + let singletonCommand: QueuedCommand | undefined + + if (data[0] === 0x01 && data[1] === 0x0b) { + // Query about Device 2 + singletonCommand = this.#pendingSingletonCommands.get(0x1c) + + if (!singletonCommand && this.#onChildInfoChange) { + // If there is no command, this is a plug event + this.#onChildInfoChange(parseDevice2Info(data)) + } + } else if (data[0] === 0x01) { + this.emit('input', data.subarray(1)) + } else if (data[0] === 0x03) { + // Command for the Studio port + singletonCommand = this.#pendingSingletonCommands.get(data[1]) + } else { + // Command for the Device 2 port + singletonCommand = this.#pendingSingletonCommands.get(data[0]) + } + + if (singletonCommand) { + const singletonCommand0 = singletonCommand + setImmediate(() => singletonCommand0.resolve(data)) + } + }) + this.#socket.on('error', (message, err) => + this.emit('error', `Socket error: ${message} (${err?.message ?? err})`), + ) + this.#socket.on('disconnected', () => { + for (const command of this.#pendingSingletonCommands.values()) { + try { + command.reject(new Error('Disconnected')) + } catch (_e) { + // Ignore + } + } + this.#pendingSingletonCommands.clear() + }) + } + + async close(): Promise { + throw new Error('Socket is owned by the connection manager, and cannot be closed directly') + // await this.#socket.close() + } + + async sendFeatureReport(data: Uint8Array): Promise { + // Ensure the buffer is 1024 bytes long + let dataFull = data + if (data.length != 1024) { + dataFull = new Uint8Array(1024) + dataFull.set(data.slice(0, Math.min(data.length, dataFull.length))) + } + + this.#socket.sendMessages([dataFull]) + } + + async getFeatureReport(reportId: number, _reportLength: number): Promise { + return this.#executeSingletonCommand(reportId, this.#isPrimary) + } + + readonly #pendingSingletonCommands = new Map() + async #executeSingletonCommand(commandType: number, isPrimary: boolean): Promise { + // if (!this.connected) throw new Error('Not connected') + + const existingCommand = this.#pendingSingletonCommands.get(commandType) + if (existingCommand) return existingCommand.promise + + const command = new QueuedCommand(commandType) + this.#pendingSingletonCommands.set(commandType, command) + + command.promise + .finally(() => { + this.#pendingSingletonCommands.delete(commandType) + }) + .catch(() => null) + + const b = Buffer.alloc(1024) + if (isPrimary) { + b.writeUint8(0x03, 0) + b.writeUint8(commandType, 1) + } else { + b.writeUint8(commandType, 0) + } + this.#socket.sendMessages([b]) + + // TODO - improve this timeout + setTimeout(() => { + command.reject(new Error('Timeout')) + }, 5000) + + return command.promise + } + + async sendReports(buffers: Buffer[]): Promise { + this.#socket.sendMessages(buffers) + } + + #loadedHidInfo: HIDDeviceInfo | undefined + async getDeviceInfo(): Promise { + // Cache once loaded. This is a bit of a race condition, but with minimal impact as we already run it before handling the class off anywhere + if (this.#loadedHidInfo) return this.#loadedHidInfo + + const deviceInfo = await Promise.race([ + // primary port + this.#executeSingletonCommand(0x80, true).then((data) => ({ data, isPrimary: true })), + // secondary port + this.#executeSingletonCommand(0x08, false).then((data) => ({ data, isPrimary: false })), + ]) + // Future: this internal mutation is a bit of a hack, but it avoids needing to duplicate the singleton logic + this.#isPrimary = deviceInfo.isPrimary + + const devicePath = `tcp://${this.#socket.address}:${this.#socket.port}` + + if (this.#isPrimary) { + const dataView = uint8ArrayToDataView(deviceInfo.data) + const vendorId = dataView.getUint16(12, true) + const productId = dataView.getUint16(14, true) + + this.#loadedHidInfo = { + vendorId: vendorId, + productId: productId, + path: devicePath, + } + } else { + const rawDevice2Info = await this.#executeSingletonCommand(0x1c, true) + const device2Info = parseDevice2Info(rawDevice2Info) + if (!device2Info) throw new Error('Failed to get Device info') + + this.#loadedHidInfo = { + vendorId: device2Info.vendorId, + productId: device2Info.productId, + path: devicePath, + } + } + + return this.#loadedHidInfo + } + + async getChildDeviceInfo(): Promise { + if (!this.#isPrimary) return null + + const device2Info = await this.#executeSingletonCommand(0x1c, true) + + return parseDevice2Info(device2Info) + } +} diff --git a/packages/tcp/src/index.ts b/packages/tcp/src/index.ts new file mode 100644 index 0000000..8b09a2a --- /dev/null +++ b/packages/tcp/src/index.ts @@ -0,0 +1,23 @@ +import type { JPEGEncodeOptions } from '@elgato-stream-deck/node-lib' + +export { + VENDOR_ID, + DeviceModelId, + KeyIndex, + StreamDeck, + LcdPosition, + Dimension, + StreamDeckControlDefinitionBase, + StreamDeckButtonControlDefinition, + StreamDeckEncoderControlDefinition, + StreamDeckLcdSegmentControlDefinition, + StreamDeckControlDefinition, + OpenStreamDeckOptions, +} from '@elgato-stream-deck/core' + +export * from './types.js' +export * from './connectionManager.js' +export * from './discoveryService.js' +export { DEFAULT_TCP_PORT } from './constants.js' + +export { JPEGEncodeOptions } diff --git a/packages/tcp/src/socketWrapper.ts b/packages/tcp/src/socketWrapper.ts new file mode 100644 index 0000000..96a35aa --- /dev/null +++ b/packages/tcp/src/socketWrapper.ts @@ -0,0 +1,172 @@ +import { Socket } from 'net' +import * as EventEmitter from 'events' +import { DEFAULT_TCP_PORT, RECONNECT_INTERVAL, TIMEOUT_DURATION } from './constants.js' + +export interface SocketWrapperEvents { + error: [str: string, e: any] + connected: [self: SocketWrapper] + disconnected: [self: SocketWrapper] + data: [data: Buffer] +} + +export class SocketWrapper extends EventEmitter { + readonly #socket: Socket + + readonly #address: string + readonly #port: number + + #connected = false + #retryConnectTimeout: NodeJS.Timeout | null = null + #connectionActive = false // True when connected/connecting/reconnecting + #lastReceived = Date.now() + #receiveBuffer: Buffer | null = null + + constructor(host: string, port: number) { + super() + + this.#socket = new Socket() + this.#socket.on('error', (e) => { + if (this.#connectionActive) { + this.emit('error', 'socket error', e) + } + }) + this.#socket.on('close', () => { + if (this.#connected) this.emit('disconnected', this) + this.#connected = false + + // if (this._pingInterval) { + // clearInterval(this._pingInterval) + // this._pingInterval = null + // } + this._triggerRetryConnection() + }) + this.#socket.on('data', (d) => this.#handleData(d)) + + this.#connectionActive = true + + this.#address = host + this.#port = port || DEFAULT_TCP_PORT + + this.#socket.connect(this.#port, this.#address) + } + + get connected(): boolean { + return this.#connected + } + + get address(): string { + return this.#address + } + get port(): number { + return this.#port + } + + public checkForTimeout(): void { + if (!this.#connectionActive) return + + if (this.#retryConnectTimeout) return + + if (this.#lastReceived + TIMEOUT_DURATION < Date.now()) { + this.#connected = false + setImmediate(() => this.emit('disconnected', this)) + + this._retryConnection() + } + } + + private _triggerRetryConnection() { + if (!this.#retryConnectTimeout) { + this.#retryConnectTimeout = setTimeout(() => { + this._retryConnection() + }, RECONNECT_INTERVAL) + } + } + private _retryConnection() { + if (this.#retryConnectTimeout) { + clearTimeout(this.#retryConnectTimeout) + this.#retryConnectTimeout = null + } + + if (!this.connected && this.#connectionActive) { + // Avoid timeouts while reconnecting + this.#lastReceived = Date.now() + + try { + this.#socket.connect(this.#port, this.#address) + } catch (e) { + this._triggerRetryConnection() + this.emit('error', 'connection failed', e) + // this._log('connection failed', e) + console.log('connection failed', e) + } + } + } + + #handleData(data: Buffer) { + this.#lastReceived = Date.now() + + // Append data to buffer + if (!this.#receiveBuffer || this.#receiveBuffer.length === 0) { + this.#receiveBuffer = data + } else { + this.#receiveBuffer = Buffer.concat([this.#receiveBuffer, data]) + } + + // Pop and handle packets + const PACKET_SIZE = 512 + while (this.#receiveBuffer.length >= PACKET_SIZE) { + const packet = this.#receiveBuffer.subarray(0, PACKET_SIZE) + this.#receiveBuffer = this.#receiveBuffer.subarray(PACKET_SIZE) + + this.#handleDataPacket(packet) + } + + // If buffer is empty, remove the reference + if (this.#receiveBuffer.length === 0) { + this.#receiveBuffer = null + } + } + + #handleDataPacket(packet: Buffer) { + if (packet[0] === 1 && packet[1] === 10) { + // Report as connected + if (!this.#connected) { + this.#connected = true + + setImmediate(() => this.emit('connected', this)) + } + + const ackBuffer = Buffer.alloc(1024) + ackBuffer.writeUInt8(3, 0) + ackBuffer.writeUInt8(26, 1) + ackBuffer.writeUInt8(packet[5], 2) // connection no + + this.#socket.write(ackBuffer) + } else { + try { + this.emit('data', packet) + } catch (e) { + this.emit('error', 'Handle data error', e) + } + } + } + + async close(): Promise { + try { + this.#connectionActive = false + if (this.#retryConnectTimeout) { + clearTimeout(this.#retryConnectTimeout) + this.#retryConnectTimeout = null + } + } finally { + this.#socket.destroy() + } + } + + sendMessages(buffers: Uint8Array[]): void { + // TODO - await write? + for (const buffer of buffers) { + this.#socket.write(buffer) + } + } +} diff --git a/packages/tcp/src/tcpWrapper.ts b/packages/tcp/src/tcpWrapper.ts new file mode 100644 index 0000000..8d277d8 --- /dev/null +++ b/packages/tcp/src/tcpWrapper.ts @@ -0,0 +1,49 @@ +import { EventEmitter } from 'eventemitter3' +import type { StreamDeck } from '@elgato-stream-deck/core' +import { StreamDeckProxy } from '@elgato-stream-deck/core' +import type { SocketWrapper } from './socketWrapper.js' +import type { StreamDeckTcp, StreamDeckTcpEvents } from './types.js' +import type { TcpHidDevice } from './hid-device.js' + +export class StreamDeckTcpWrapper extends StreamDeckProxy implements StreamDeckTcp { + readonly #socket: SocketWrapper + readonly #device: TcpHidDevice + readonly #tcpEvents = new EventEmitter() + + get remoteAddress(): string { + return this.#socket.address + } + get remotePort(): number { + return this.#socket.port + } + + get tcpEvents(): EventEmitter { + return this.#tcpEvents + } + + constructor(socket: SocketWrapper, device: TcpHidDevice, streamdeck: StreamDeck) { + super(streamdeck) + + this.#socket = socket + this.#device = device + + this.#socket.on('disconnected', () => { + setImmediate(() => this.#tcpEvents.emit('disconnected')) + }) + + // Forward child info changes + if (this.#device.isPrimary) { + this.#device.onChildInfoChange = (info) => { + this.#tcpEvents.emit('childChange', info) + } + } + } + + async getMacAddress(): Promise { + if (!this.#device.isPrimary) throw new Error('Not supported on secondary devices') + + const data = await this.#device.getFeatureReport(0x85, -1) + + return new TextDecoder('hex').decode(data.subarray(4, 10)) // TODO - add colons + } +} diff --git a/packages/tcp/src/types.ts b/packages/tcp/src/types.ts new file mode 100644 index 0000000..c0b0549 --- /dev/null +++ b/packages/tcp/src/types.ts @@ -0,0 +1,28 @@ +import type { DeviceModelId, ChildHIDDeviceInfo, OpenStreamDeckOptions, StreamDeck } from '@elgato-stream-deck/core' +import type { JPEGEncodeOptions } from '@elgato-stream-deck/node-lib' +import type { EventEmitter } from 'eventemitter3' + +export interface OpenStreamDeckOptionsTcp extends OpenStreamDeckOptions { + /** JPEG quality options for default jpeg encoder */ + jpegOptions?: JPEGEncodeOptions + /** Whether to auto-connect to any streamdecks discovered to be connected to a manually specified streamdeck */ + autoConnectToSecondaries?: boolean +} + +export interface StreamDeckTcpEvents { + disconnected: [] + childChange: [info: ChildHIDDeviceInfo | null] +} + +export interface StreamDeckChildDeviceInfo extends ChildHIDDeviceInfo { + readonly model: DeviceModelId +} + +export interface StreamDeckTcp extends StreamDeck { + readonly tcpEvents: EventEmitter + + readonly remoteAddress: string + readonly remotePort: number + + getMacAddress(): Promise +} diff --git a/packages/tcp/tsconfig.build.json b/packages/tcp/tsconfig.build.json new file mode 100644 index 0000000..edb61ee --- /dev/null +++ b/packages/tcp/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "exclude": ["node_modules/**", "src/**/*spec.ts", "src/**/__tests__/*", "src/**/__mocks__/*"], + "include": ["src/**/*"], + "compilerOptions": { + "outDir": "./dist", + "baseUrl": "./", + "paths": { + "*": ["./node_modules/*"], + "@elgato-stream-deck/tcp": ["./src/index.ts"] + }, + "types": ["node"] + } +} diff --git a/packages/tcp/tsconfig.json b/packages/tcp/tsconfig.json new file mode 100644 index 0000000..39cf967 --- /dev/null +++ b/packages/tcp/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.build.json", + "exclude": ["node_modules/**"], + "compilerOptions": { + "types": ["jest", "node"] + } +} diff --git a/packages/webhid/src/hid-device.ts b/packages/webhid/src/hid-device.ts index 76235fb..074026b 100644 --- a/packages/webhid/src/hid-device.ts +++ b/packages/webhid/src/hid-device.ts @@ -1,5 +1,6 @@ import type { HIDDevice as CoreHIDDevice, HIDDeviceEvents, HIDDeviceInfo } from '@elgato-stream-deck/core' -import * as EventEmitter from 'eventemitter3' +import type { ChildHIDDeviceInfo } from '@elgato-stream-deck/core/dist/hid-device' +import { EventEmitter } from 'eventemitter3' import Queue from 'p-queue' /** @@ -55,4 +56,9 @@ export class WebHIDDevice extends EventEmitter implements CoreH vendorId: this.device.vendorId, } } + + public async getChildDeviceInfo(): Promise { + // Not supported + return null + } } diff --git a/packages/webhid/udev/50-elgato-stream-deck-user.rules b/packages/webhid/udev/50-elgato-stream-deck-user.rules index 5661390..d6d31d7 100644 --- a/packages/webhid/udev/50-elgato-stream-deck-user.rules +++ b/packages/webhid/udev/50-elgato-stream-deck-user.rules @@ -8,6 +8,7 @@ SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0084", MODE="660", SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE="660", TAG+="uaccess" SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE="660", TAG+="uaccess" SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="009A", MODE="660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00AA", MODE="660", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", MODE="660", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", MODE="660", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", MODE="660", TAG+="uaccess" @@ -17,3 +18,4 @@ KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0084", MODE="660" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE="660", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE="660", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="009A", MODE="660", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00AA", MODE="660", TAG+="uaccess" diff --git a/tsconfig.build.json b/tsconfig.build.json index 19b8e4c..f712d07 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,7 +2,9 @@ "files": [], "references": [ { "path": "./packages/core/tsconfig.build.json" }, + { "path": "./packages/node-lib/tsconfig.build.json" }, { "path": "./packages/node/tsconfig.build.json" }, + { "path": "./packages/tcp/tsconfig.build.json" }, { "path": "./packages/webhid/tsconfig.build.json" }, { "path": "./packages/webhid-demo/tsconfig.json" } ] diff --git a/tsconfig.json b/tsconfig.json index 40db0cd..abc4469 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,9 @@ "files": [], "references": [ { "path": "./packages/core/tsconfig.json" }, + { "path": "./packages/node-lib/tsconfig.json" }, { "path": "./packages/node/tsconfig.json" }, + { "path": "./packages/tcp/tsconfig.json" }, { "path": "./packages/webhid/tsconfig.json" }, { "path": "./packages/webhid-demo/tsconfig.json" } ] diff --git a/yarn.lock b/yarn.lock index 2ff197d..57ff1fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -476,17 +476,38 @@ __metadata: languageName: unknown linkType: soft +"@elgato-stream-deck/node-lib@npm:7.0.0-0, @elgato-stream-deck/node-lib@workspace:packages/node-lib": + version: 0.0.0-use.local + resolution: "@elgato-stream-deck/node-lib@workspace:packages/node-lib" + dependencies: + jpeg-js: "npm:^0.4.4" + tslib: "npm:^2.6.3" + peerDependencies: + "@julusian/jpeg-turbo": ^1.1.2 || ^2.0.0 + languageName: unknown + linkType: soft + "@elgato-stream-deck/node@workspace:packages/node": version: 0.0.0-use.local resolution: "@elgato-stream-deck/node@workspace:packages/node" dependencies: "@elgato-stream-deck/core": "npm:7.0.0-0" + "@elgato-stream-deck/node-lib": "npm:7.0.0-0" eventemitter3: "npm:^5.0.1" - jpeg-js: "npm:^0.4.4" node-hid: "npm:^3.1.0" tslib: "npm:^2.7.0" - peerDependencies: - "@julusian/jpeg-turbo": ^1.1.2 || ^2.0.0 + languageName: unknown + linkType: soft + +"@elgato-stream-deck/tcp@workspace:packages/tcp": + version: 0.0.0-use.local + resolution: "@elgato-stream-deck/tcp@workspace:packages/tcp" + dependencies: + "@elgato-stream-deck/core": "npm:7.0.0-0" + "@elgato-stream-deck/node-lib": "npm:7.0.0-0" + "@julusian/bonjour-service": "npm:^1.3.0-2" + eventemitter3: "npm:^5.0.1" + tslib: "npm:^2.6.3" languageName: unknown linkType: soft @@ -1515,6 +1536,16 @@ __metadata: languageName: node linkType: hard +"@julusian/bonjour-service@npm:^1.3.0-2": + version: 1.3.0-2 + resolution: "@julusian/bonjour-service@npm:1.3.0-2" + dependencies: + fast-deep-equal: "npm:^3.1.3" + multicast-dns: "npm:^7.2.5" + checksum: 10c0/efb004b4c4d3214a166bdd81e04f39dcee6055a2a991192c40059bda302377d909176a3967baeac3714681e88a2b3274763e19897e1ed00e27298f602f90fffd + languageName: node + linkType: hard + "@julusian/jpeg-turbo@npm:^2.1.0": version: 2.1.0 resolution: "@julusian/jpeg-turbo@npm:2.1.0" @@ -3540,13 +3571,6 @@ __metadata: languageName: node linkType: hard -"array-flatten@npm:^2.1.2": - version: 2.1.2 - resolution: "array-flatten@npm:2.1.2" - checksum: 10c0/bdc1cee68e41bec9cfc1161408734e2269428ef371445606bce4e6241001e138a94b9a617cc9a5b4b7fe6a3a51e3d5a942646975ce82a2e202ccf3e9b478c82f - languageName: node - linkType: hard - "array-ify@npm:^1.0.0": version: 1.0.0 resolution: "array-ify@npm:1.0.0" @@ -3783,14 +3807,12 @@ __metadata: linkType: hard "bonjour-service@npm:^1.0.11": - version: 1.1.1 - resolution: "bonjour-service@npm:1.1.1" + version: 1.2.1 + resolution: "bonjour-service@npm:1.2.1" dependencies: - array-flatten: "npm:^2.1.2" - dns-equal: "npm:^1.0.0" fast-deep-equal: "npm:^3.1.3" multicast-dns: "npm:^7.2.5" - checksum: 10c0/8dd3fef3ff8a11678d8f586be03c85004a45bae4353c55d7dbffe288cad73ddb38dee08b57425b9945c9a3a840d50bd40ae5aeda0066186dabe4b84a315b4e05 + checksum: 10c0/953cbfc27fc9e36e6f988012993ab2244817d82426603e0390d4715639031396c932b6657b1aa4ec30dbb5fa903d6b2c7f1be3af7a8ba24165c93e987c849730 languageName: node linkType: hard @@ -4945,13 +4967,6 @@ __metadata: languageName: node linkType: hard -"dns-equal@npm:^1.0.0": - version: 1.0.0 - resolution: "dns-equal@npm:1.0.0" - checksum: 10c0/da966e5275ac50546e108af6bc29aaae2164d2ae96d60601b333c4a3aff91f50b6ca14929cf91f20a9cad1587b356323e300cea3ff6588a6a816988485f445f1 - languageName: node - linkType: hard - "dns-packet@npm:^5.2.2": version: 5.6.1 resolution: "dns-packet@npm:5.6.1" @@ -11961,7 +11976,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.7.0": +"tslib@npm:^2, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.6.3, tslib@npm:^2.7.0": version: 2.7.0 resolution: "tslib@npm:2.7.0" checksum: 10c0/469e1d5bf1af585742128827000711efa61010b699cb040ab1800bcd3ccdd37f63ec30642c9e07c4439c1db6e46345582614275daca3e0f4abae29b0083f04a6