Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Shift Registers for Shift+Trigger operations #11

Merged
merged 8 commits into from
Nov 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/input-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/input-gateway/src/$schemas/options.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
23 changes: 23 additions & 0 deletions packages/input-gateway/src/SHIFT_REGISTERS.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/input-gateway/src/generated/options.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
172 changes: 155 additions & 17 deletions packages/input-gateway/src/inputManagerHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -44,14 +49,17 @@ export class InputManagerHandler {
#coreHandler!: CoreHandler
#config!: Config
#deviceSettings: DeviceSettings | undefined
#triggersSubscriptionId: string | undefined
#triggersSubscriptionId: SubscriptionId | undefined
#logger: Winston.Logger
#process!: Process

#inputManager: InputManager | undefined

#queue: PQueue

#shiftRegisters: number[] = []
#deviceTriggerActions: Record<string, Record<string, DeviceActionArguments>> = {}

#observers: Observer[] = []
/** Set of deviceIds to check for triggers to send */
#devicesWithTriggersToSend = new Set<string>()
Expand All @@ -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('------------------------------------------------------')
Expand Down Expand Up @@ -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}`))
})
Expand Down Expand Up @@ -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`)

Expand All @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, SomeDeviceConfig>): Promise<InputManager> {
const manager = new InputManager(
{
Expand Down Expand Up @@ -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)
)
}
Expand All @@ -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<void> {
if (!this.#inputManager) return

this.#deviceTriggerActions = {}
await this.#inputManager.clearFeedbackAll()
}

Expand Down
36 changes: 36 additions & 0 deletions packages/input-manager/src/devices/feedbackStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { SomeFeedback } from '../feedback/feedback'

export class FeedbackStore<T extends SomeFeedback> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: You're not really using this class as a generic anywhere, perhaps just use SomeFeedback directly?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I'm not using it right now, but the plan was that the FeedbackStore could be used for objects enhancing the SomeFeedback interface, that's why it's generic.

private feedbacks: Record<string, Record<string, T>> = {}

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<string, T> | 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))
}
}
1 change: 1 addition & 0 deletions packages/input-manager/src/generated/streamdeck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export interface StreamDeckDeviceOptions {
path?: string
serialNumber?: string
index?: number
brightness?: number
}
Loading