diff --git a/packages/input-gateway/package.json b/packages/input-gateway/package.json index d4f924f..02d4270 100644 --- a/packages/input-gateway/package.json +++ b/packages/input-gateway/package.json @@ -73,8 +73,8 @@ ], "dependencies": { "@sofie-automation/input-manager": "0.2.2-alpha.1", - "@sofie-automation/server-core-integration": "1.50.0-nightly-release50-20230529-135607-13157b3.0", - "@sofie-automation/shared-lib": "1.50.0-nightly-release50-20230529-135607-13157b3.0", + "@sofie-automation/server-core-integration": "1.51.0-nightly-feat-input-gw-shift-20230731-125304-43763fe.0", + "@sofie-automation/shared-lib": "1.51.0-nightly-feat-input-gw-shift-20230731-125304-43763fe.0", "debug": "^4.3.1", "eventemitter3": "^4.0.7", "p-all": "^3.0.0", diff --git a/packages/input-gateway/src/$schemas/options.json b/packages/input-gateway/src/$schemas/options.json index bbc27d9..b6082d9 100644 --- a/packages/input-gateway/src/$schemas/options.json +++ b/packages/input-gateway/src/$schemas/options.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:local:nrk.no/sofie-input-gateway/gateway-settings", - "title": "Package Manager Settings", + "title": "Input Gateway Settings", "type": "object", "properties": { "logLevel": { diff --git a/packages/input-gateway/src/SHIFT_REGISTERS.md b/packages/input-gateway/src/SHIFT_REGISTERS.md new file mode 100644 index 0000000..cf0a7a9 --- /dev/null +++ b/packages/input-gateway/src/SHIFT_REGISTERS.md @@ -0,0 +1,23 @@ +Shift Registers +=== + +In order to support a "generic" way of doing multiple "layers" of controls on a single control surface (or a group of them), the concept of "modifier keys" had to be generalized. This has been done by coming up with the concept of _Shift Registers_. + +A Shift Register is a numbered integer variable that is global to a given instance of Input Gateway. Actions mounted to the Triggers of various Input Devices can then modify these Shift Registers using simple operations: Add (`+`), Subtract (`-`) and Set (`=`) and a single operand value. Since the Add and Subtract operations do not have any stop conditions, in order for their behavior to be more predictable, additional `min` and `max` properties to clamp the resulting Shift Register to a range after the operation is done. + +The state of all of the Shift Registers in an Input Gateway is prepended to the "triggerId" string of all emitted triggers using the following algorithm: + +* If all Shift Registers are set to 0 (their initial value), the prefix is an empty string. _This allows backwards compatibility if one is not using Shift Registers at all._ +* If a Shift Register is set to a value other than 0, iterate through the Shift Registers and concatenate their values, joined with a `:` character, until the last non-zero Shift Register is found. An example Shift Registers state prefix will look look something like this: `[1:2:0:1]` + +Since this "Shift Registers" prefix is effectively changing the triggers themselves, this also affects the feedback displayed by the Input Devices. + +For example: by adding the "Change Shift Register" actions to various _button down_ and _button up_ triggers, one can choose to build various interaction models: + +* Shift + Button interactions +* Latching Shift buttons +* Cascading menus +* Folders +* Or even number inputs + +In order to get the desired interaction model, it may be neccessary to add the same action to multiple triggers (the same physical trigger with various Shift Register prefixes) or to split actions on prefixed and unprefixed triggers. \ No newline at end of file diff --git a/packages/input-gateway/src/generated/options.d.ts b/packages/input-gateway/src/generated/options.d.ts index f70cac2..26bbe31 100644 --- a/packages/input-gateway/src/generated/options.d.ts +++ b/packages/input-gateway/src/generated/options.d.ts @@ -5,7 +5,7 @@ * and run json-schema-to-typescript to regenerate this file. */ -export interface PackageManagerSettings { +export interface InputGatewaySettings { logLevel?: "error" | "warn" | "info" | "verbose" | "debug" | "silly"; [k: string]: unknown; } diff --git a/packages/input-gateway/src/inputManagerHandler.ts b/packages/input-gateway/src/inputManagerHandler.ts index c4d586b..3002d3f 100644 --- a/packages/input-gateway/src/inputManagerHandler.ts +++ b/packages/input-gateway/src/inputManagerHandler.ts @@ -5,9 +5,11 @@ import { CoreHandler } from './coreHandler' import { DeviceSettings } from './interfaces' import { PeripheralDeviceId } from '@sofie-automation/shared-lib/dist/core/model/Ids' import { + DeviceActionArguments, DeviceTriggerMountedAction, DeviceTriggerMountedActionId, PreviewWrappedAdLib, + ShiftRegisterActionArguments, } from '@sofie-automation/shared-lib/dist/input-gateway/deviceTriggerPreviews' import { SourceLayerType } from '@sofie-automation/shared-lib/dist/core/model/ShowStyle' import { Process } from './process' @@ -24,12 +26,15 @@ import { import { interpollateTranslation, translateMessage } from './lib/translatableMessage' import { protectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' import { ITranslatableMessage } from '@sofie-automation/shared-lib/dist/lib/translations' -import { Observer } from '@sofie-automation/server-core-integration' +import { Observer, SubscriptionId } from '@sofie-automation/server-core-integration' import { sleep } from '@sofie-automation/shared-lib/dist/lib/lib' import PQueue from 'p-queue' +import { InputGatewaySettings } from './generated/options' export type SetProcessState = (processName: string, comments: string[], status: StatusCode) => void +const DEFAULT_LOG_LEVEL = 'info' + export interface ProcessConfig { /** Will cause the Node applocation to blindly accept all certificates. Not recommenced unless in local, controlled networks. */ unsafeSSL: boolean @@ -44,7 +49,7 @@ export class InputManagerHandler { #coreHandler!: CoreHandler #config!: Config #deviceSettings: DeviceSettings | undefined - #triggersSubscriptionId: string | undefined + #triggersSubscriptionId: SubscriptionId | undefined #logger: Winston.Logger #process!: Process @@ -52,6 +57,9 @@ export class InputManagerHandler { #queue: PQueue + #shiftRegisters: number[] = [] + #deviceTriggerActions: Record> = {} + #observers: Observer[] = [] /** Set of deviceIds to check for triggers to send */ #devicesWithTriggersToSend = new Set() @@ -75,6 +83,10 @@ export class InputManagerHandler { const peripheralDevice = await this.#coreHandler.core.getPeripheralDevice() + const gatewaySettings = peripheralDevice.deviceSettings as InputGatewaySettings + + this.#logger.level = gatewaySettings?.logLevel ?? DEFAULT_LOG_LEVEL + // Stop here if studioId not set if (!peripheralDevice.studioId) { this.#logger.warn('------------------------------------------------------') @@ -140,13 +152,13 @@ export class InputManagerHandler { this.#logger.info(`Subscribed to mountedTriggersForDevice: ${this.#triggersSubscriptionId}`) - this.#refreshMountedTriggers() + await this.#refreshMountedTriggers() this.#coreHandler.onConnected(() => { this.#logger.info(`Core reconnected`) this.#handleClearAllMountedTriggers() - .then(() => { - this.#refreshMountedTriggers() + .then(async () => { + await this.#refreshMountedTriggers() }) .catch((err) => this.#logger.error(`Error in refreshMountedTriggers() on coreHandler.onConnected: ${err}`)) }) @@ -267,6 +279,9 @@ export class InputManagerHandler { if (_.isEqual(device.inputDevices, this.#deviceSettings)) return const settings: DeviceSettings = device.inputDevices as DeviceSettings + const gatewaySettings: InputGatewaySettings = device.deviceSettings as InputGatewaySettings + + this.#logger.level = gatewaySettings?.logLevel ?? DEFAULT_LOG_LEVEL this.#logger.debug(`Device configuration changed`) @@ -290,23 +305,33 @@ export class InputManagerHandler { InputManagerHandler.getDeviceIds(settings) ) - this.#refreshMountedTriggers() + await this.#refreshMountedTriggers() }) .catch(() => { this.#logger.error(`coreHandler.onChanged: Could not get peripheral device`) }) } - #refreshMountedTriggers() { - this.#coreHandler.core + async #refreshMountedTriggers() { + this.#deviceTriggerActions = {} + + if (!this.#inputManager) return + + const endReplaceTransaction = this.#inputManager.beginFeedbackReplaceTransaction() + + const mountedActions = this.#coreHandler.core .getCollection('mountedTriggers') - .find({}) - .forEach((obj) => { - const mountedTrigger = obj as DeviceTriggerMountedAction - this.#handleChangedMountedTrigger(mountedTrigger._id).catch((err) => - this.#logger.error(`Error in #handleChangedMountedTrigger in #refreshMountedTriggers: ${err}`) - ) - }) + .find({}) as DeviceTriggerMountedAction[] + + for (const mountedTrigger of mountedActions) { + try { + await this.#handleChangedMountedTrigger(mountedTrigger._id) + } catch (err) { + this.#logger.error(`Error in #handleChangedMountedTrigger in #refreshMountedTriggers: ${err}`) + } + } + + await endReplaceTransaction() } #triggerSendTrigger() { @@ -335,6 +360,9 @@ export class InputManagerHandler { // Nothing left to send. return } + triggerToSend.triggerId = this.#shiftPrefixTriggerId(triggerToSend.triggerId) + + this.#executeDeviceAction(deviceId, triggerToSend) this.#logger.verbose(`Trigger send...`) this.#logger.verbose(triggerToSend.triggerId) @@ -371,6 +399,95 @@ export class InputManagerHandler { }) } + #executeDeviceAction(deviceId: string, trigger: TriggerEvent): void { + const deviceAction: DeviceActionArguments | undefined = this.#deviceTriggerActions[deviceId]?.[trigger.triggerId] + if (!deviceAction) return + + this.#logger.debug(`Executing Device Action: ${deviceAction.type}: ${JSON.stringify(deviceAction)}`) + + if (deviceAction.type === 'modifyRegister') this.#executeModifyShiftRegister(deviceAction) + } + + #executeModifyShiftRegister(action: ShiftRegisterActionArguments): void { + const registerIndex = Number(action.register) + + if (registerIndex < 0 || !Number.isInteger(registerIndex)) { + this.#logger.error(`Register index needs to be a non-negative integer: received "${action.register}" in action"`) + return + } + + const value = Number(action.value) + const min = Number(action.limitMin) + const max = Number(action.limitMax) + + const originalValue = this.#shiftRegisters[registerIndex] ?? 0 + let newValue = originalValue + switch (action.operation) { + case '=': + newValue = value + break + case '+': + newValue += value + break + case '-': + newValue -= value + break + } + + newValue = Math.max(Math.min(newValue, max), min) + + this.#shiftRegisters[registerIndex] = newValue + + this.#refreshMountedTriggers().catch(this.#logger.error) + } + + #SHIFT_PREFIX_REGEX = /^\[([\d:]+)\]\s+(.+)$/ + + #shiftPrefixTriggerId(triggerId: string): string { + const shiftPrefix = this.#serializeShiftRegisters() + if (shiftPrefix === '') { + return triggerId + } + return `${shiftPrefix} ${triggerId}` + } + + #shiftUnprefixTriggerId(prefixedTriggerId: string): [number[], string] { + const match = this.#SHIFT_PREFIX_REGEX.exec(prefixedTriggerId) + if (!match) return [[], prefixedTriggerId] + + const shiftStates = match[1].split(':').map((shiftRegister) => Number(shiftRegister)) + const triggerId = match[2] + return [shiftStates, triggerId] + } + + #matchesCurrentShiftState(shiftState: number[]): boolean { + const maxLength = Math.max(shiftState.length, this.#shiftRegisters.length) + for (let i = 0; i < maxLength; i++) { + if ((shiftState[i] ?? 0) !== (this.#shiftRegisters[i] ?? 0)) return false + } + return true + } + + #serializeShiftRegisters(): string { + const output: string[] = [] + const buffer: string[] = [] + const maxRegister = this.#shiftRegisters.length + for (let i = 0; i < maxRegister; i++) { + const curValue = this.#shiftRegisters[i] ?? 0 + if (curValue !== 0) { + output.push(...buffer) + output.push(String(curValue)) + buffer.length = 0 + } else { + buffer.push(String(curValue)) + } + } + + if (output.length === 0) return '' + + return `[${output.join(':')}]` + } + async #createInputManager(settings: Record): Promise { const manager = new InputManager( { @@ -398,9 +515,18 @@ export class InputManagerHandler { this.#logger.debug(`Setting feedback for "${feedbackDeviceId}", "${feedbackTriggerId}"`) if (!feedbackDeviceId || !feedbackTriggerId) return + if (mountedTrigger.deviceActionArguments) { + this.#deviceTriggerActions[feedbackDeviceId] = this.#deviceTriggerActions[feedbackDeviceId] ?? {} + this.#deviceTriggerActions[feedbackDeviceId][feedbackTriggerId] = mountedTrigger.deviceActionArguments + } + + const [shiftState, unshiftedTriggerId] = this.#shiftUnprefixTriggerId(feedbackTriggerId) + + if (!this.#matchesCurrentShiftState(shiftState)) return + await this.#inputManager.setFeedback( feedbackDeviceId, - feedbackTriggerId, + unshiftedTriggerId, await this.#getFeedbackForMountedTrigger(mountedTrigger) ) } @@ -413,12 +539,24 @@ export class InputManagerHandler { this.#logger.debug(`Removing feedback for "${feedbackDeviceId}", "${feedbackTriggerId}"`) if (!feedbackDeviceId || !feedbackTriggerId) return - await this.#inputManager.setFeedback(feedbackDeviceId, feedbackTriggerId, null) + if ( + this.#deviceTriggerActions[feedbackDeviceId] && + this.#deviceTriggerActions[feedbackDeviceId][feedbackTriggerId] + ) { + delete this.#deviceTriggerActions[feedbackDeviceId][feedbackTriggerId] + } + + const [shiftState, unshiftedTriggerId] = this.#shiftUnprefixTriggerId(feedbackTriggerId) + + if (!this.#matchesCurrentShiftState(shiftState)) return + + await this.#inputManager.setFeedback(feedbackDeviceId, unshiftedTriggerId, null) } async #handleClearAllMountedTriggers(): Promise { if (!this.#inputManager) return + this.#deviceTriggerActions = {} await this.#inputManager.clearFeedbackAll() } diff --git a/packages/input-manager/src/devices/feedbackStore.ts b/packages/input-manager/src/devices/feedbackStore.ts new file mode 100644 index 0000000..ee8c448 --- /dev/null +++ b/packages/input-manager/src/devices/feedbackStore.ts @@ -0,0 +1,36 @@ +import { SomeFeedback } from '../feedback/feedback' + +export class FeedbackStore { + private feedbacks: Record> = {} + + public set(feedbackId: string, triggerId: string, feedback: T): void { + if (this.feedbacks[feedbackId] === undefined) { + this.feedbacks[feedbackId] = {} + } + + this.feedbacks[feedbackId][triggerId] = feedback + } + + public get(feedbackId: string, acceptedTriggerIds: string[]): T | null + public get(feedbackId: string, triggerId: string): T | null + public get(feedbackId: string, triggerId: string | string[]): T | null { + const triggersInPriority = Array.isArray(triggerId) ? triggerId : [triggerId] + + const feedbackObj = this.feedbacks[feedbackId] as Record | undefined + if (!feedbackObj) return null + + for (const trigger of triggersInPriority) { + if (feedbackObj[trigger]) return feedbackObj[trigger] + } + + return null + } + + public clear(): void { + this.feedbacks = {} + } + + public allFeedbackIds(): string[] { + return Array.from(Object.keys(this.feedbacks)) + } +} diff --git a/packages/input-manager/src/generated/streamdeck.ts b/packages/input-manager/src/generated/streamdeck.ts index 7d37e0e..f0a763e 100644 --- a/packages/input-manager/src/generated/streamdeck.ts +++ b/packages/input-manager/src/generated/streamdeck.ts @@ -9,4 +9,5 @@ export interface StreamDeckDeviceOptions { path?: string serialNumber?: string index?: number + brightness?: number } diff --git a/packages/input-manager/src/inputManager.ts b/packages/input-manager/src/inputManager.ts index 476d201..1bb1faf 100644 --- a/packages/input-manager/src/inputManager.ts +++ b/packages/input-manager/src/inputManager.ts @@ -213,9 +213,28 @@ class InputManager extends EventEmitter { } async clearFeedbackAll(): Promise { - for (const [deviceId, device] of Object.entries(this.#devices)) { + const p = Object.entries(this.#devices).map(async ([deviceId, device]) => { this.#feedback[deviceId] = {} await device?.clearFeedbackAll() + }) + await Promise.allSettled(p) + } + + beginFeedbackReplaceTransaction(): () => Promise { + const oldFeedback = this.#feedback + this.#feedback = {} + + return async () => { + // set null feedback on all triggers that are not in the new feedback cache + const p = Object.entries>(oldFeedback).map(async ([deviceId, deviceTriggersObj]) => { + for (const [triggerId, feedback] of Object.entries(deviceTriggersObj)) { + if (this.#feedback[deviceId]?.[triggerId] === undefined && feedback !== undefined) { + this.#logger.debug(`Clearing ${deviceId} "${triggerId}"...`) + await this.setFeedback(deviceId, triggerId, null) + } + } + }) + await Promise.allSettled(p) } } diff --git a/packages/input-manager/src/integrations/skaarhoj/index.ts b/packages/input-manager/src/integrations/skaarhoj/index.ts index 9ed86a4..74936ae 100644 --- a/packages/input-manager/src/integrations/skaarhoj/index.ts +++ b/packages/input-manager/src/integrations/skaarhoj/index.ts @@ -1,6 +1,7 @@ import net from 'net' import { Logger } from '../../logger' import { Device } from '../../devices/device' +import { FeedbackStore } from '../../devices/feedbackStore' import { DEFAULT_ANALOG_RATE_LIMIT, Symbols } from '../../lib' import { ClassNames, Label, SomeFeedback, Tally } from '../../feedback/feedback' import { SkaarhojPanelOptions } from '../../generated' @@ -16,7 +17,7 @@ export class SkaarhojDevice extends Device { #socket: net.Socket | undefined #closing = false #config: SkaarhojPanelOptions - #feedbacks: Record = {} + #feedbacks = new FeedbackStore() constructor(config: SkaarhojPanelOptions, logger: Logger) { super(logger) @@ -92,9 +93,17 @@ export class SkaarhojDevice extends Device { } else { const stateMatch = state.match(AnalogStateChange.StateChange) if (stateMatch) { + const value = parseFloat(stateMatch[2]) + const key = stateMatch[1] + + let direction = 0 + if (value < 0) direction = -1 + if (value > 0) direction = 1 + this.updateTriggerAnalog({ triggerId, rateLimit: DEFAULT_ANALOG_RATE_LIMIT }, () => { return { - [stateMatch[1]]: parseFloat(stateMatch[2]), + [key]: value, + direction, } }) } else { @@ -113,14 +122,14 @@ export class SkaarhojDevice extends Device { return new Promise((resolve) => socket.end(resolve)) } - private static parseTriggerId(triggerId: string): [string, boolean] { + private static parseTriggerId(triggerId: string): { buttonId: string; action: string } { const triggerElements = triggerId.match(/^(\d+)(.\d+)?\s(\S+)$/) if (!triggerElements) { - return ['0', false] + return { buttonId: '0', action: '' } } const buttonId = triggerElements[1] ?? '0' - const isUp = triggerElements[3] === Symbols.UP - return [buttonId, isUp] + const action = triggerElements[3] + return { buttonId, action } } private async sendClearFeedback(key: string): Promise { @@ -144,10 +153,10 @@ export class SkaarhojDevice extends Device { return result } - private async updateFeedback(key: string): Promise { - const feedback = this.#feedbacks[key] + private async updateFeedback(feedbackId: string): Promise { + const feedback = this.#feedbacks.get(feedbackId, ACTION_PRIORITIES) if (!feedback) { - await this.sendClearFeedback(key) + await this.sendClearFeedback(feedbackId) return } @@ -185,7 +194,7 @@ export class SkaarhojDevice extends Device { hasFilledTitle = false if (!feedback.content) { - await this.sendClearFeedback(key) + await this.sendClearFeedback(feedbackId) return } } @@ -194,24 +203,21 @@ export class SkaarhojDevice extends Device { if (line1) line1 = SkaarhojDevice.normalizeString(line1).trim() if (line2) line2 = SkaarhojDevice.normalizeString(line2).trim() - await this.sendToDevice(`HWC#${key}=${tallyColor}`) + await this.sendToDevice(`HWC#${feedbackId}=${tallyColor}`) await this.sendToDevice( - `HWCt#${key}=|||${title ?? ''}|${hasFilledTitle ? '' : '1'}|${line1 ?? 'UNKNOWN'}|${line2}|` + `HWCt#${feedbackId}=|||${title ?? ''}|${hasFilledTitle ? '' : '1'}|${line1 ?? 'UNKNOWN'}|${line2}|` ) } async setFeedback(triggerId: string, feedback: SomeFeedback): Promise { if (!this.#socket) return - const [button] = SkaarhojDevice.parseTriggerId(triggerId) - this.#feedbacks[button] = feedback - await this.updateFeedback(button) + const { buttonId, action } = SkaarhojDevice.parseTriggerId(triggerId) + this.#feedbacks.set(buttonId, action, feedback) + await this.updateFeedback(buttonId) } async clearFeedbackAll(): Promise { - for (const keyStr of Object.keys(this.#feedbacks)) { - this.#feedbacks[keyStr] = null - await this.updateFeedback(keyStr) - } + this.#feedbacks.clear() if (!this.#socket) return await this.sendToDevice('Clear') } @@ -247,3 +253,5 @@ const InboundMessages = { const AnalogStateChange = { StateChange: /^(\w+):([\d-.]+)$/, } + +const ACTION_PRIORITIES = [Symbols.DOWN, Symbols.UP, Symbols.JOG, Symbols.MOVE, Symbols.SHUTTLE, Symbols.T_BAR] diff --git a/packages/input-manager/src/integrations/streamdeck/$schemas/options.json b/packages/input-manager/src/integrations/streamdeck/$schemas/options.json index e8fd0c6..df85e95 100644 --- a/packages/input-manager/src/integrations/streamdeck/$schemas/options.json +++ b/packages/input-manager/src/integrations/streamdeck/$schemas/options.json @@ -18,6 +18,13 @@ "type": "integer", "ui:title": "Device Index", "ui:description": "The index on the list of attached Stream Deck devices" + }, + "brightness": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "ui:title": "Display brightness", + "ui:description": "Set the intensity of the backlight on the Stream Deck device screen" } }, "required": [], diff --git a/packages/input-manager/src/integrations/streamdeck/index.ts b/packages/input-manager/src/integrations/streamdeck/index.ts index 8edd9d5..eecdeeb 100644 --- a/packages/input-manager/src/integrations/streamdeck/index.ts +++ b/packages/input-manager/src/integrations/streamdeck/index.ts @@ -1,6 +1,7 @@ import { listStreamDecks, openStreamDeck, StreamDeck } from '@elgato-stream-deck/node' import { Logger } from '../../logger' import { Device } from '../../devices/device' +import { FeedbackStore } from '../../devices/feedbackStore' import { DEFAULT_ANALOG_RATE_LIMIT, Symbols } from '../../lib' import { SomeFeedback } from '../../feedback/feedback' import { getBitmap } from '../../feedback/bitmap' @@ -11,7 +12,8 @@ import DEVICE_OPTIONS from './$schemas/options.json' export class StreamDeckDevice extends Device { #streamDeck: StreamDeck | undefined #config: StreamDeckDeviceOptions - #feedbacks: Record = {} + #feedbacks = new FeedbackStore() + #isButtonDown: Record = {} private BTN_SIZE: number | undefined = undefined private ENC_SIZE_WIDTH: number | undefined = undefined private ENC_SIZE_HEIGHT: number | undefined = undefined @@ -48,7 +50,9 @@ export class StreamDeckDevice extends Device { this.ENC_SIZE_HEIGHT = this.#streamDeck.LCD_ENCODER_SIZE?.height this.ENC_SIZE_WIDTH = this.#streamDeck.LCD_ENCODER_SIZE?.width - this.#streamDeck.setBrightness(100).catch((err) => { + const brightness = this.#config.brightness ?? DEFAULT_BRIGHTNESS + + this.#streamDeck.setBrightness(brightness).catch((err) => { this.logger.error(`Error setting brightness: ${err}`, err) }) this.#streamDeck.addListener('down', (key) => { @@ -57,7 +61,9 @@ export class StreamDeckDevice extends Device { this.addTriggerEvent({ triggerId }) - this.updateFeedback(id, true).catch((err) => this.logger.error(`Stream Deck: Error updating feedback: ${err}`)) + this.#isButtonDown[id] = true + + this.quietUpdateFeedbackWithDownState(id) }) this.#streamDeck.addListener('up', (key) => { const id = `${key}` @@ -65,7 +71,9 @@ export class StreamDeckDevice extends Device { this.addTriggerEvent({ triggerId }) - this.updateFeedback(id, false).catch((err) => this.logger.error(`Stream Deck: Error updating feedback: ${err}`)) + this.#isButtonDown[id] = false + + this.quietUpdateFeedbackWithDownState(id) }) this.#streamDeck.addListener('encoderDown', (encoder) => { const id = `Enc${encoder}` @@ -73,7 +81,9 @@ export class StreamDeckDevice extends Device { this.addTriggerEvent({ triggerId }) - this.updateFeedback(id, true).catch((err) => this.logger.error(`Stream Deck: Error updating feedback: ${err}`)) + this.#isButtonDown[id] = true + + this.quietUpdateFeedbackWithDownState(id) }) this.#streamDeck.addListener('encoderUp', (encoder) => { const id = `Enc${encoder}` @@ -81,7 +91,9 @@ export class StreamDeckDevice extends Device { this.addTriggerEvent({ triggerId }) - this.updateFeedback(id, false).catch((err) => this.logger.error(`Stream Deck: Error updating feedback: ${err}`)) + this.#isButtonDown[id] = false + + this.quietUpdateFeedbackWithDownState(id) }) this.#streamDeck.addListener('rotateLeft', (encoder, deltaValue) => { const id = `Enc${encoder}` @@ -91,10 +103,11 @@ export class StreamDeckDevice extends Device { if (!prev) prev = { deltaValue: 0 } return { deltaValue: prev.deltaValue - deltaValue, + direction: -1, } }) - this.updateFeedback(id, false).catch((err) => this.logger.error(`Stream Deck: Error updating feedback: ${err}`)) + this.quietUpdateFeedbackWithDownState(id) }) this.#streamDeck.addListener('rotateRight', (encoder, deltaValue) => { const id = `Enc${encoder}` @@ -104,10 +117,11 @@ export class StreamDeckDevice extends Device { if (!prev) prev = { deltaValue: 0 } return { deltaValue: prev.deltaValue + deltaValue, + direction: 1, } }) - this.updateFeedback(id, false).catch((err) => this.logger.error(`Stream Deck: Error updating feedback: ${err}`)) + this.quietUpdateFeedbackWithDownState(id) }) this.#streamDeck.addListener('lcdShortPress', (encoder, position) => { const id = `Enc${encoder}` @@ -121,7 +135,7 @@ export class StreamDeckDevice extends Device { }, }) - this.updateFeedback(id, false).catch((err) => this.logger.error(`Stream Deck: Error updating feedback: ${err}`)) + this.quietUpdateFeedbackWithDownState(id) }) this.#streamDeck.addListener('lcdLongPress', (encoder, position) => { const id = `Enc${encoder}` @@ -135,7 +149,7 @@ export class StreamDeckDevice extends Device { }, }) - this.updateFeedback(id, false).catch((err) => this.logger.error(`Stream Deck: Error updating feedback: ${err}`)) + this.quietUpdateFeedbackWithDownState(id) }) this.#streamDeck.addListener('lcdSwipe', (fromEncoder, toEncoder, from, to) => { const id = `Enc${fromEncoder}` @@ -153,7 +167,7 @@ export class StreamDeckDevice extends Device { }, }) - this.updateFeedback(id, false).catch((err) => this.logger.error(`Stream Deck: Error updating feedback: ${err}`)) + this.quietUpdateFeedbackWithDownState(id) }) this.#streamDeck.addListener('error', (err) => { this.logger.error(String(err)) @@ -172,30 +186,29 @@ export class StreamDeckDevice extends Device { id: string key: number | undefined encoder: number | undefined - isUp: boolean - isUpDown: boolean + action: string } { const triggerElements = triggerId.split(/\s+/) const id = triggerElements[0] ?? '0' - const isUp = triggerElements[1] === Symbols.UP - const isUpDown = triggerElements[1] === Symbols.UP || triggerElements[1] === Symbols.DOWN + const action = triggerElements[1] let key: number | undefined = undefined let encoder: number | undefined = undefined let result = null if ((result = id.match(/^Enc(\d+)$/))) { encoder = Number(result[1]) ?? 0 - return { id, key, encoder, isUp, isUpDown } + return { id, key, encoder, action } } key = Number(id) ?? 0 - return { id, key, encoder, isUp, isUpDown } + return { id, key, encoder, action } } private async updateFeedback(trigger: string, isDown: boolean): Promise { const streamdeck = this.#streamDeck if (!streamdeck) return - const feedback = this.#feedbacks[trigger] - const { key, encoder } = StreamDeckDevice.parseTriggerId(trigger) + const { id, key, encoder } = StreamDeckDevice.parseTriggerId(trigger) + + const feedback = this.#feedbacks.get(id, ACTION_PRIORITIES) try { if (!feedback) { @@ -226,27 +239,35 @@ export class StreamDeckDevice extends Device { } } + private quietUpdateFeedbackWithDownState(trigger: string): void { + this.updateFeedback(trigger, this.#isButtonDown[trigger] ?? false).catch((err) => + this.logger.error(`Stream Deck: Error updating feedback: ${err}`) + ) + } + async setFeedback(triggerId: string, feedback: SomeFeedback): Promise { if (!this.#streamDeck) return - const { id: trigger, isUpDown } = StreamDeckDevice.parseTriggerId(triggerId) + const { id: trigger, action } = StreamDeckDevice.parseTriggerId(triggerId) - if (!isUpDown) return + this.#feedbacks.set(trigger, action, feedback) - this.#feedbacks[trigger] = feedback - - await this.updateFeedback(trigger, false) + await this.updateFeedback(trigger, this.#isButtonDown[trigger] ?? false) } async clearFeedbackAll(): Promise { - for (const keyStr of Object.keys(this.#feedbacks)) { + for (const keyStr of this.#feedbacks.allFeedbackIds()) { const key = keyStr - this.#feedbacks[key] = null await this.updateFeedback(key, false) } + + this.#feedbacks.clear() } static getOptionsManifest(): object { return DEVICE_OPTIONS } } + +const ACTION_PRIORITIES = [Symbols.DOWN, Symbols.UP, Symbols.JOG, Symbols.MOVE, Symbols.SHUTTLE, Symbols.T_BAR] +const DEFAULT_BRIGHTNESS = 100 diff --git a/packages/input-manager/src/integrations/xkeys/index.ts b/packages/input-manager/src/integrations/xkeys/index.ts index c18b168..553dc6e 100644 --- a/packages/input-manager/src/integrations/xkeys/index.ts +++ b/packages/input-manager/src/integrations/xkeys/index.ts @@ -97,8 +97,14 @@ export class XKeysDevice extends Device { this.updateTriggerAnalog({ triggerId, rateLimit: DEFAULT_ANALOG_RATE_LIMIT }, (prev?: { deltaValue: number }) => { if (!prev) prev = { deltaValue: 0 } + + let direction = 0 + if (deltaValue < 0) direction = -1 + if (deltaValue > 0) direction = 1 + return { deltaValue: prev.deltaValue + deltaValue, + direction, } }) }) @@ -108,6 +114,7 @@ export class XKeysDevice extends Device { this.updateTriggerAnalog({ triggerId, rateLimit: DEFAULT_ANALOG_RATE_LIMIT }, (prev?: { position: number }) => { if (!prev) prev = { position: 0 } + return { position: prev.position + position, } diff --git a/yarn.lock b/yarn.lock index a5f7174..bea35c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1058,10 +1058,10 @@ __metadata: languageName: node linkType: hard -"@mos-connection/model@npm:^3.0.2": - version: 3.0.2 - resolution: "@mos-connection/model@npm:3.0.2" - checksum: c8acb3eff5845e71a33349131547be8ca1c8b041f5119d54253f03ad1ce7a2b21d30dbaa4709a978425237585cc4b7eab2edca8027d81984aefd9cdef9c3efc4 +"@mos-connection/model@npm:^3.0.4": + version: 3.0.4 + resolution: "@mos-connection/model@npm:3.0.4" + checksum: 7de15fba9a818260fb57847dbc51695036720a7f499d2fe262c691630673e83c01bdc3ea62261529e661f957074a6d92e8d38305485293c5392e073dab33989b languageName: node linkType: hard @@ -1832,30 +1832,30 @@ __metadata: languageName: unknown linkType: soft -"@sofie-automation/server-core-integration@npm:1.50.0-nightly-release50-20230529-135607-13157b3.0": - version: 1.50.0-nightly-release50-20230529-135607-13157b3.0 - resolution: "@sofie-automation/server-core-integration@npm:1.50.0-nightly-release50-20230529-135607-13157b3.0" +"@sofie-automation/server-core-integration@npm:1.51.0-nightly-feat-input-gw-shift-20230731-125304-43763fe.0": + version: 1.51.0-nightly-feat-input-gw-shift-20230731-125304-43763fe.0 + resolution: "@sofie-automation/server-core-integration@npm:1.51.0-nightly-feat-input-gw-shift-20230731-125304-43763fe.0" dependencies: - "@sofie-automation/shared-lib": 1.50.0-nightly-release50-20230529-135607-13157b3.0 + "@sofie-automation/shared-lib": 1.51.0-nightly-feat-input-gw-shift-20230731-125304-43763fe.0 ejson: ^2.2.3 eventemitter3: ^4.0.7 faye-websocket: ^0.11.4 got: ^11.8.6 - tslib: ^2.4.0 - underscore: ^1.13.4 - checksum: 0a096dfd650171ef49e53191df85bbd82af0dc508ab4f69e15791fb14748c0e51afa0a71db887eb1c49f48014c4df6f954fcecf9dd85075f675d49d564b6d54b + tslib: ^2.6.0 + underscore: ^1.13.6 + checksum: 10ef3107c495f77b941795672a3c8669a4d44f13cff5c0ce528f57ab9a81c4b96db6189f0adbef228b1c3ce511221557095ddcdb99b762cd4d4c0ecb9788d97d languageName: node linkType: hard -"@sofie-automation/shared-lib@npm:1.50.0-nightly-release50-20230529-135607-13157b3.0": - version: 1.50.0-nightly-release50-20230529-135607-13157b3.0 - resolution: "@sofie-automation/shared-lib@npm:1.50.0-nightly-release50-20230529-135607-13157b3.0" +"@sofie-automation/shared-lib@npm:1.51.0-nightly-feat-input-gw-shift-20230731-125304-43763fe.0": + version: 1.51.0-nightly-feat-input-gw-shift-20230731-125304-43763fe.0 + resolution: "@sofie-automation/shared-lib@npm:1.51.0-nightly-feat-input-gw-shift-20230731-125304-43763fe.0" dependencies: - "@mos-connection/model": ^3.0.2 - timeline-state-resolver-types: 8.0.0-nightly-release50-20230518-100641-f60ce9709.0 - tslib: ^2.4.0 - type-fest: ^2.19.0 - checksum: 91011eaa0ee74a48f705c8a7bbfdbccfdb0bd7a131a14753778ddb603e624f1c8756ee4dee5eef02d32d209b12fb0d327a166fb96a099e1bdd9211fb0d412696 + "@mos-connection/model": ^3.0.4 + timeline-state-resolver-types: 9.0.0-release50.5 + tslib: ^2.6.0 + type-fest: ^3.10.0 + checksum: 0e818c60a2bacd81f765c6773a1ba9dcd5d5a09887701fea44d4dce443e560db98a9a8f8b1c46f1a033e808ec2af89b010a475049b2b84fd2f15fd52f334c2e4 languageName: node linkType: hard @@ -6531,8 +6531,8 @@ __metadata: resolution: "input-gateway@workspace:packages/input-gateway" dependencies: "@sofie-automation/input-manager": 0.2.2-alpha.1 - "@sofie-automation/server-core-integration": 1.50.0-nightly-release50-20230529-135607-13157b3.0 - "@sofie-automation/shared-lib": 1.50.0-nightly-release50-20230529-135607-13157b3.0 + "@sofie-automation/server-core-integration": 1.51.0-nightly-feat-input-gw-shift-20230731-125304-43763fe.0 + "@sofie-automation/shared-lib": 1.51.0-nightly-feat-input-gw-shift-20230731-125304-43763fe.0 debug: ^4.3.1 eventemitter3: ^4.0.7 p-all: ^3.0.0 @@ -12282,12 +12282,12 @@ __metadata: languageName: node linkType: hard -"timeline-state-resolver-types@npm:8.0.0-nightly-release50-20230518-100641-f60ce9709.0": - version: 8.0.0-nightly-release50-20230518-100641-f60ce9709.0 - resolution: "timeline-state-resolver-types@npm:8.0.0-nightly-release50-20230518-100641-f60ce9709.0" +"timeline-state-resolver-types@npm:9.0.0-release50.5": + version: 9.0.0-release50.5 + resolution: "timeline-state-resolver-types@npm:9.0.0-release50.5" dependencies: tslib: ^2.5.1 - checksum: 02db63f168af7085899919e7d5c60cb9cbdb427263b16faab0d6393ef44215320b131467c0ff207685e02e6cf7a339979e8813515624e81867064d55fd06b392 + checksum: bb333705c2aaccc70698333e8d13b9b95d58a5767402270b9181fee7da22fc0732d95b950e77f24f1429c5e7c789e9604fd66f0e13ea52d801320f69337ecf1a languageName: node linkType: hard @@ -12559,6 +12559,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:^2.6.0": + version: 2.6.1 + resolution: "tslib@npm:2.6.1" + checksum: b0d176d176487905b66ae4d5856647df50e37beea7571c53b8d10ba9222c074b81f1410fb91da13debaf2cbc970663609068bdebafa844ea9d69b146527c38fe + languageName: node + linkType: hard + "tsutils@npm:^3.21.0": version: 3.21.0 resolution: "tsutils@npm:3.21.0" @@ -12671,7 +12678,7 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^2.0.0, type-fest@npm:^2.12.2, type-fest@npm:^2.13.0, type-fest@npm:^2.16.0, type-fest@npm:^2.19.0, type-fest@npm:^2.5.0": +"type-fest@npm:^2.0.0, type-fest@npm:^2.12.2, type-fest@npm:^2.13.0, type-fest@npm:^2.16.0, type-fest@npm:^2.5.0": version: 2.19.0 resolution: "type-fest@npm:2.19.0" checksum: a4ef07ece297c9fba78fc1bd6d85dff4472fe043ede98bd4710d2615d15776902b595abf62bd78339ed6278f021235fb28a96361f8be86ed754f778973a0d278 @@ -12685,6 +12692,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^3.10.0": + version: 3.13.1 + resolution: "type-fest@npm:3.13.1" + checksum: c06b0901d54391dc46de3802375f5579868949d71f93b425ce564e19a428a0d411ae8d8cb0e300d330071d86152c3ea86e744c3f2860a42a79585b6ec2fdae8e + languageName: node + linkType: hard + "type@npm:^1.0.1": version: 1.2.0 resolution: "type@npm:1.2.0" @@ -12778,7 +12792,7 @@ __metadata: languageName: node linkType: hard -"underscore@npm:^1.13.4": +"underscore@npm:^1.13.4, underscore@npm:^1.13.6": version: 1.13.6 resolution: "underscore@npm:1.13.6" checksum: d5cedd14a9d0d91dd38c1ce6169e4455bb931f0aaf354108e47bd46d3f2da7464d49b2171a5cf786d61963204a42d01ea1332a903b7342ad428deaafaf70ec36