diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..0e748b0 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,10 @@ +// @ts-check + +/** @type {import('@babel/core').ConfigFunction} */ +module.exports = (api) => { + api.cache.forever(); + + return { + presets: [['@babel/preset-env', { targets: { node: '8' } }]], + }; +}; diff --git a/package.json b/package.json index 859c2ae..c770254 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "license-validate": "node-license-validator -p -d --allow-licenses MIT BSD BSD-3-Clause BSD-2-Clause ISC Apache Apache-2.0 WTFPL Unlicense --allow-packages cycle" }, "devDependencies": { + "@babel/preset-env": "^7.10.3", "@types/jest": "^26.0.3", "@types/npm-packlist": "^1.1.1", "@types/pino": "^6.3.0", diff --git a/src/HyperDeckServer.ts b/src/HyperDeckServer.ts index 4241927..94865b1 100644 --- a/src/HyperDeckServer.ts +++ b/src/HyperDeckServer.ts @@ -1,53 +1,26 @@ import { HyperDeckSocket } from './HyperDeckSocket'; import type { ReceivedCommandCallback } from './HyperDeckSocket'; -import { DeserializedCommand, SynchronousCode, ErrorCode, NotifyType } from './types'; -import * as ResponseInterface from './types/ResponseInterface'; -import * as DeserializedCommands from './types/DeserializedCommands'; -import { formatClipsGetResponse } from './formatClipsGetResponse'; +import { SynchronousCode, ErrorCode, NotifyType, CommandHandler, CommandResponse } from './types'; import { createServer, Server } from 'net'; import pino from 'pino'; +import { CommandName, paramsByCommandName } from './api'; +import { formatClipsGetResponse } from './formatClipsGetResponse'; import { invariant } from './invariant'; -type Handler = (command: C) => Promise; - -class UnimplementedError extends Error {} - -const noop = async () => { - throw new UnimplementedError(); +const internalCommands = { + remote: true, + notify: true, + watchdog: true, + ping: true, }; +type SupportedCommands = Exclude; + export class HyperDeckServer { private logger: pino.Logger; private sockets: { [id: string]: HyperDeckSocket } = {}; private server: Server; - onDeviceInfo: Handler = noop; - onDiskList: Handler = noop; - onPreview: Handler = noop; - onPlay: Handler = noop; - onPlayrangeSet: Handler = noop; - onPlayrangeClear: Handler = noop; - onRecord: Handler = noop; - onStop: Handler = noop; - onClipsCount: Handler = noop; - onClipsGet: Handler = noop; - onClipsAdd: Handler = noop; - onClipsClear: Handler = noop; - onTransportInfo: Handler = noop; - onSlotInfo: Handler = noop; - onSlotSelect: Handler = noop; - onGoTo: Handler = noop; - onJog: Handler = noop; - onShuttle: Handler = noop; - onConfiguration: Handler< - DeserializedCommands.ConfigurationCommand, - ResponseInterface.Configuration - > = noop; - onUptime: Handler = noop; - onFormat: Handler = noop; - onIdentify: Handler = noop; - onWatchdog: Handler = noop; - constructor(ip?: string, logger = pino()) { this.logger = logger.child({ name: 'HyperDeck Emulator' }); @@ -57,9 +30,7 @@ export class HyperDeckServer { const socketLogger = this.logger.child({ name: 'HyperDeck socket ' + socketId }); - this.sockets[socketId] = new HyperDeckSocket(socket, socketLogger, (cmd) => - this.receivedCommand(cmd) - ); + this.sockets[socketId] = new HyperDeckSocket(socket, socketLogger, this.receivedCommand); this.sockets[socketId].on('disconnected', () => { socketLogger.info('disconnected'); @@ -92,162 +63,115 @@ export class HyperDeckServer { } } - private receivedCommand: ReceivedCommandCallback = async (cmd) => { - // TODO(meyer) more sophisticated debouncing - await new Promise((resolve) => setTimeout(() => resolve(), 200)); - - this.logger.info({ cmd }, '<-- ' + cmd.name); - try { - if (cmd.name === 'device info') { - const res = await this.onDeviceInfo(cmd); - return { code: SynchronousCode.DeviceInfo, params: res }; - } - - if (cmd.name === 'disk list') { - const res = await this.onDiskList(cmd); - return { code: SynchronousCode.DiskList, params: res }; - } + private commandHandlers: { [K in CommandName]?: CommandHandler } = {}; - if (cmd.name === 'preview') { - await this.onPreview(cmd); - return SynchronousCode.OK; - } + public on = (key: T, handler: CommandHandler): void => { + invariant(paramsByCommandName.hasOwnProperty(key), 'Invalid key: `%s`', key); - if (cmd.name === 'play') { - await this.onPlay(cmd); - return SynchronousCode.OK; - } + invariant( + !this.commandHandlers.hasOwnProperty(key), + 'Handler already registered for `%s`', + key + ); - if (cmd.name === 'playrange set') { - await this.onPlayrangeSet(cmd); - return SynchronousCode.OK; - } - - if (cmd.name === 'playrange clear') { - await this.onPlayrangeClear(cmd); - return SynchronousCode.OK; - } - - if (cmd.name === 'record') { - await this.onRecord(cmd); - return SynchronousCode.OK; - } - - if (cmd.name === 'stop') { - await this.onStop(cmd); - return SynchronousCode.OK; - } - - if (cmd.name === 'clips count') { - const res = await this.onClipsCount(cmd); - return { code: SynchronousCode.ClipsCount, params: res }; - } - - if (cmd.name === 'clips get') { - const res = await this.onClipsGet(cmd).then(formatClipsGetResponse); - return { code: SynchronousCode.ClipsInfo, params: res }; - } - - if (cmd.name === 'clips add') { - await this.onClipsAdd(cmd); - return SynchronousCode.OK; - } - - if (cmd.name === 'clips clear') { - await this.onClipsClear(cmd); - return SynchronousCode.OK; - } + this.commandHandlers[key] = handler as any; + }; - if (cmd.name === 'transport info') { - const res = await this.onTransportInfo(cmd); - return { code: SynchronousCode.TransportInfo, params: res }; - } + private receivedCommand: ReceivedCommandCallback = async (cmd) => { + // TODO(meyer) more sophisticated debouncing + await new Promise((resolve) => setTimeout(() => resolve(), 200)); - if (cmd.name === 'slot info') { - const res = await this.onSlotInfo(cmd); - return { code: SynchronousCode.SlotInfo, params: res }; - } + this.logger.info({ cmd }, 'receivedCommand %s', cmd.name); - if (cmd.name === 'slot select') { - await this.onSlotSelect(cmd); - return SynchronousCode.OK; - } + if (cmd.name === 'remote') { + return { + code: SynchronousCode.Remote, + params: { + enabled: true, + override: false, + }, + }; + } - if (cmd.name === 'notify') { - // implemented in socket.ts - return SynchronousCode.OK; - } + // implemented in socket.ts + if (cmd.name === 'notify' || cmd.name === 'watchdog' || cmd.name === 'ping') { + return SynchronousCode.OK; + } - if (cmd.name === 'go to') { - await this.onGoTo(cmd); - return SynchronousCode.OK; - } + const handler = this.commandHandlers[cmd.name]; + if (!handler) { + this.logger.error({ cmd }, 'unimplemented'); + return ErrorCode.Unsupported; + } - if (cmd.name === 'jog') { - await this.onJog(cmd); - return SynchronousCode.OK; - } + const response = await handler(cmd); + + const result: CommandResponse = { + name: cmd.name, + response, + } as any; + + if ( + result.name === 'clips add' || + result.name === 'clips clear' || + result.name === 'goto' || + result.name === 'identify' || + result.name === 'jog' || + result.name === 'play' || + result.name === 'playrange clear' || + result.name === 'playrange set' || + result.name === 'preview' || + result.name === 'record' || + result.name === 'shuttle' || + result.name === 'slot select' || + result.name === 'stop' + ) { + return SynchronousCode.OK; + } - if (cmd.name === 'shuttle') { - await this.onShuttle(cmd); - return SynchronousCode.OK; - } + if (result.name === 'device info') { + return { code: SynchronousCode.DeviceInfo, params: result.response }; + } - if (cmd.name === 'remote') { - return { - code: SynchronousCode.Remote, - params: { - enabled: true, - override: false, - }, - }; - } + if (result.name === 'disk list') { + return { code: SynchronousCode.DiskList, params: result.response }; + } - if (cmd.name === 'configuration') { - const res = await this.onConfiguration(cmd); - if (res) { - return { code: SynchronousCode.Configuration, params: res }; - } - return SynchronousCode.OK; - } + if (result.name === 'clips count') { + return { code: SynchronousCode.ClipsCount, params: result.response }; + } - if (cmd.name === 'uptime') { - const res = await this.onUptime(cmd); - return { code: SynchronousCode.Uptime, params: res }; - } + if (result.name === 'clips get') { + return { code: SynchronousCode.ClipsInfo, params: formatClipsGetResponse(result.response) }; + } - if (cmd.name === 'format') { - const res = await this.onFormat(cmd); - if (res) { - return { code: SynchronousCode.FormatReady, params: res }; - } - return SynchronousCode.OK; - } + if (result.name === 'transport info') { + return { code: SynchronousCode.TransportInfo, params: result.response }; + } - if (cmd.name === 'identify') { - await this.onIdentify(cmd); - return SynchronousCode.OK; - } + if (result.name === 'slot info') { + return { code: SynchronousCode.SlotInfo, params: result.response }; + } - if (cmd.name === 'watchdog') { - // implemented in socket.ts - return SynchronousCode.OK; + if (result.name === 'configuration') { + if (result) { + return { code: SynchronousCode.Configuration, params: result.response }; } + return SynchronousCode.OK; + } - if (cmd.name === 'ping') { - // implemented in socket.ts - return SynchronousCode.OK; - } + if (result.name === 'uptime') { + return { code: SynchronousCode.Uptime, params: result.response }; + } - invariant(false, 'Unhandled command name: `%s`', cmd.name); - } catch (err) { - if (err instanceof UnimplementedError) { - this.logger.error({ cmd }, 'unimplemented'); - return ErrorCode.Unsupported; + if (result.name === 'format') { + if (result) { + return { code: SynchronousCode.FormatReady, params: result.response }; } - - this.logger.error({ cmd, err: err.message }, 'unhandled command name'); - return ErrorCode.InternalError; + return SynchronousCode.OK; } + + this.logger.error({ cmd, res: result }, 'Unsupported command'); + return ErrorCode.Unsupported; }; } diff --git a/src/HyperDeckSocket.ts b/src/HyperDeckSocket.ts index 95ee769..a15b3b5 100644 --- a/src/HyperDeckSocket.ts +++ b/src/HyperDeckSocket.ts @@ -7,8 +7,9 @@ import { NotifyType, SynchronousCode, ResponseCode, + DeserializedCommandsByName, + TypesByStringKey, } from './types'; -import * as DeserializedCommands from './types/DeserializedCommands'; import { MultilineParser } from './MultilineParser'; import type { Logger } from 'pino'; import { messageForCode } from './messageForCode'; @@ -60,12 +61,19 @@ export class HyperDeckSocket extends EventEmitter { private lastReceivedMS = -1; private watchdogTimer: NodeJS.Timer | null = null; - private notifySettings = { + private notifySettings: Record< + keyof DeserializedCommandsByName['notify']['parameters'], + boolean + > = { + configuration: false, + displayTimecode: false, + droppedFrames: false, + dynamicRange: false, + playrange: false, + remote: false, slot: false, + timelinePosition: false, transport: false, - remote: false, - configuration: false, - 'dropped frames': false, }; private onMessage(data: string): void { @@ -81,7 +89,7 @@ export class HyperDeckSocket extends EventEmitter { if (cmd.name === 'watchdog') { if (this.watchdogTimer) clearInterval(this.watchdogTimer); - const watchdogCmd = cmd as DeserializedCommands.WatchdogCommand; + const watchdogCmd = cmd as DeserializedCommandsByName['watchdog']; if (watchdogCmd.parameters.period) { this.watchdogTimer = setInterval(() => { if (Date.now() - this.lastReceivedMS > Number(watchdogCmd.parameters.period)) { @@ -94,14 +102,14 @@ export class HyperDeckSocket extends EventEmitter { }, Number(watchdogCmd.parameters.period) * 1000); } } else if (cmd.name === 'notify') { - const notifyCmd = cmd as DeserializedCommands.NotifyCommand; + const notifyCmd = cmd as DeserializedCommandsByName['notify']; if (Object.keys(notifyCmd.parameters).length > 0) { for (const param of Object.keys(notifyCmd.parameters) as Array< keyof typeof notifyCmd.parameters >) { if (this.notifySettings[param] !== undefined) { - this.notifySettings[param] = notifyCmd.parameters[param] === 'true'; + this.notifySettings[param] = notifyCmd.parameters[param] === true; } } } else { @@ -151,7 +159,7 @@ export class HyperDeckSocket extends EventEmitter { sendResponse( code: ResponseCode, - paramsOrMessage?: Record | string, + paramsOrMessage?: Record | string, cmd?: DeserializedCommand ): void { try { diff --git a/src/MultilineParser.ts b/src/MultilineParser.ts index b8b56c9..54dc45b 100644 --- a/src/MultilineParser.ts +++ b/src/MultilineParser.ts @@ -70,7 +70,7 @@ export class MultilineParser { raw: lines.join(CRLF), name: firstLine, parameters: {}, - }; + } as DeserializedCommand; } // single-line command with params @@ -120,7 +120,7 @@ export class MultilineParser { raw: lines.join(CRLF), name: commandName, parameters: params, - }; + } as DeserializedCommand; } invariant( @@ -155,7 +155,7 @@ export class MultilineParser { raw: lines.join(CRLF), name: commandName, parameters: params, - }; + } as DeserializedCommand; return res; } catch (err) { diff --git a/src/__tests__/messageForCode.spec.ts b/src/__tests__/messageForCode.spec.ts index 0a18075..378ddd5 100644 --- a/src/__tests__/messageForCode.spec.ts +++ b/src/__tests__/messageForCode.spec.ts @@ -28,7 +28,7 @@ describe('messageForCode', () => { }); it('filters out null and undefined values', () => { - expect(messageForCode(SynchronousCode.OK, { param1: null, param2: undefined })) + expect(messageForCode(SynchronousCode.OK, { param1: null, param2: undefined } as any)) .toMatchInlineSnapshot(` "200 ok " @@ -49,7 +49,7 @@ describe('messageForCode', () => { it('throws an error if a non-primitive param type is encountered', () => { expect(() => - messageForCode(SynchronousCode.OK, { param1: { hmmm: true } }) - ).toThrowErrorMatchingInlineSnapshot(`"Unhandled value type: \`object\`"`); + messageForCode(SynchronousCode.OK, { param1: { hmmm: true } as any }) + ).toThrowErrorMatchingInlineSnapshot(`"Unhandled value type for key \`param1\`: \`object\`"`); }); }); diff --git a/src/__tests__/meta.spec.ts b/src/__tests__/meta.spec.ts index 9a3a3bf..c45c454 100644 --- a/src/__tests__/meta.spec.ts +++ b/src/__tests__/meta.spec.ts @@ -67,10 +67,6 @@ describe('npm publish', () => { - dist/Timecode.d.ts.map - dist/types.d.ts - dist/types.d.ts.map - - dist/types/DeserializedCommands.d.ts - - dist/types/DeserializedCommands.d.ts.map - - dist/types/ResponseInterface.d.ts - - dist/types/ResponseInterface.d.ts.map - dist/utils.d.ts - dist/utils.d.ts.map - src/__tests__/HyperDeckServer.spec.ts @@ -89,8 +85,6 @@ describe('npm publish', () => { - src/MultilineParser.ts - src/Timecode.ts - src/types.ts - - src/types/DeserializedCommands.ts - - src/types/ResponseInterface.ts - src/utils.ts " `); diff --git a/src/api.ts b/src/api.ts index 509eebb..4f68163 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,10 +1,14 @@ import { invariant } from './invariant'; import { camelcaseToSpaceCase } from './utils'; -import { ArgKey } from './types'; +import { ArgKey, ArgsTypes } from './types'; -interface Option = Record> { +interface Option< + A extends Record = Record, + R extends Record = Record +> { description: string; - arguments?: T; + arguments?: A; + returnValue: R; } type ParamMap = Record; @@ -16,13 +20,20 @@ interface ParamInfo { } /** Internal container class that holds metadata about each HyperDeck event */ -class HyperDeckAPI { - constructor(private readonly options: T = {} as any) {} +class HyperDeckAPI

{ + constructor( + // public only because TS apparently strips types from private methods + public readonly options: P = {} as any + ) {} - public addOption = = {}>( + public addOption = < + K extends string, + A extends Record = {}, + R extends Record = {} + >( key: K | [K, ...string[]], - option: Option - ): HyperDeckAPI }> => { + option: Option + ): HyperDeckAPI

}> => { const k = Array.isArray(key) ? key[0] : key; invariant(!this.options.hasOwnProperty(k), 'option already exists for key `%s`', k); // NOTE: this mutates the original options object @@ -32,7 +43,7 @@ class HyperDeckAPI { }; /** Get a `Set` of param names keyed by function name */ - public getParamsByCommandName = (): { [K in keyof T]: Record } => + public getParamsByCommandName = (): { [K in keyof P]: Record } => Object.entries(this.options).reduce>>( (prev, [commandName, value]) => { if (!value.arguments) { @@ -59,30 +70,46 @@ class HyperDeckAPI { const api = new HyperDeckAPI() .addOption(['help', '?'], { description: 'Provides help text on all commands and parameters', + returnValue: {}, }) .addOption('commands', { description: 'return commands in XML format', + returnValue: { + commands: 'string', + }, }) .addOption('device info', { description: 'return device information', + returnValue: { + protocolVersion: 'string', + model: 'string', + slotCount: 'string', + }, }) .addOption('disk list', { description: 'query clip list on active disk', arguments: { slotId: 'number', }, + returnValue: { + slotId: 'number', + // TODO(meyer) array of clips + }, }) .addOption('quit', { description: 'disconnect ethernet control', + returnValue: {}, }) .addOption('ping', { description: 'check device is responding', + returnValue: {}, }) .addOption('preview', { description: 'switch to preview or output', arguments: { enable: 'boolean', }, + returnValue: {}, }) .addOption('play', { description: 'play from current timecode', @@ -91,9 +118,13 @@ const api = new HyperDeckAPI() loop: 'boolean', singleClip: 'boolean', }, + returnValue: {}, }) .addOption('playrange', { description: 'query playrange setting', + returnValue: { + // TODO(meyer) this isn't accurate + }, }) .addOption('playrange set', { description: 'set play range to play clip {n} only', @@ -107,9 +138,11 @@ const api = new HyperDeckAPI() timelineIn: 'number', timelineOut: 'number', }, + returnValue: {}, }) .addOption('playrange clear', { description: 'clear/reset play range setting', + returnValue: {}, }) .addOption('play on startup', { description: 'query unit play on startup state', @@ -118,30 +151,41 @@ const api = new HyperDeckAPI() enable: 'boolean', singleClip: 'boolean', }, + // TODO(meyer) verify that there's no return value + returnValue: {}, }) .addOption('play option', { description: 'query play options', arguments: { stopMode: 'stopmode', }, + // TODO(meyer) + returnValue: {}, }) .addOption('record', { description: 'record from current input', arguments: { name: 'string', }, + returnValue: {}, }) .addOption('record spill', { description: 'spill current recording to next slot', arguments: { slotId: 'number', }, + // TODO(meyer) + returnValue: {}, }) .addOption('stop', { description: 'stop playback or recording', + returnValue: {}, }) .addOption('clips count', { description: 'query number of clips on timeline', + returnValue: { + clipCount: 'number', + }, }) .addOption('clips get', { description: 'query all timeline clips', @@ -150,6 +194,9 @@ const api = new HyperDeckAPI() count: 'number', version: 'number', }, + returnValue: { + clips: 'clips', + }, }) .addOption('clips add', { description: 'append a clip to timeline', @@ -159,24 +206,46 @@ const api = new HyperDeckAPI() in: 'timecode', out: 'timecode', }, + returnValue: {}, }) .addOption('clips remove', { description: 'remove clip {n} from the timeline (invalidates clip ids following clip {n})', arguments: { clipId: 'number', }, + // TODO(meyer) verify this + returnValue: {}, }) .addOption('clips clear', { description: 'empty timeline clip list', + returnValue: {}, }) .addOption('transport info', { description: 'query current activity', + returnValue: { + status: 'transportstatus', + speed: 'number', + slotId: 'number', + clipId: 'number', + singleClip: 'boolean', + displayTimecode: 'timecode', + timecode: 'timecode', + videoFormat: 'videoformat', + loop: 'boolean', + }, }) .addOption('slot info', { description: 'query active slot', arguments: { slotId: 'number', }, + returnValue: { + slotId: 'number', + status: 'slotstatus', + volumeName: 'string', + recordingTime: 'timecode', + videoFormat: 'videoformat', + }, }) .addOption('slot select', { description: 'switch to specified slot', @@ -184,12 +253,15 @@ const api = new HyperDeckAPI() slotId: 'number', videoFormat: 'videoformat', }, + returnValue: {}, }) .addOption('slot unblock', { description: 'unblock active slot', arguments: { slotId: 'number', }, + // TODO(meyer) verify this + returnValue: {}, }) .addOption('dynamic range', { description: 'query dynamic range settings', @@ -197,6 +269,8 @@ const api = new HyperDeckAPI() // TODO(meyer) is this correct? playbackOverride: 'string', }, + // TODO(meyer) + returnValue: {}, }) .addOption('notify', { description: 'query notification status', @@ -211,6 +285,17 @@ const api = new HyperDeckAPI() playrange: 'boolean', dynamicRange: 'boolean', }, + returnValue: { + remote: 'boolean', + transport: 'boolean', + slot: 'boolean', + configuration: 'boolean', + droppedFrames: 'boolean', + displayTimecode: 'boolean', + timelinePosition: 'boolean', + playrange: 'boolean', + dynamicRange: 'boolean', + }, }) .addOption('goto', { description: 'go forward or backward within a clip or timeline', @@ -221,18 +306,21 @@ const api = new HyperDeckAPI() timecode: 'timecode', slotId: 'number', }, + returnValue: {}, }) .addOption('jog', { description: 'jog forward or backward', arguments: { timecode: 'timecode', }, + returnValue: {}, }) .addOption('shuttle', { description: 'shuttle with speed', arguments: { speed: 'number', }, + returnValue: {}, }) .addOption('remote', { description: 'query unit remote control state', @@ -240,6 +328,8 @@ const api = new HyperDeckAPI() enable: 'boolean', override: 'boolean', }, + // TODO(meyer) + returnValue: {}, }) .addOption('configuration', { description: 'query configuration settings', @@ -255,9 +345,24 @@ const api = new HyperDeckAPI() recordPrefix: 'string', appendTimestamp: 'boolean', }, + returnValue: { + videoInput: 'videoinput', + audioInput: 'audioinput', + fileFormat: 'fileformat', + audioCodec: 'audiocodec', + timecodeInput: 'timecodeinput', + timecodePreset: 'timecode', + audioInputChannels: 'number', + recordTrigger: 'recordtrigger', + recordPrefix: 'string', + appendTimestamp: 'boolean', + }, }) .addOption('uptime', { description: 'return time since last boot', + returnValue: { + uptime: 'number', + }, }) .addOption('format', { description: 'prepare a disk formatting operation to filesystem {format}', @@ -265,23 +370,40 @@ const api = new HyperDeckAPI() prepare: 'string', confirm: 'string', }, + returnValue: { + token: 'string', + }, }) .addOption('identify', { description: 'identify the device', arguments: { enable: 'boolean', }, + // TODO(meyer) verify + returnValue: {}, }) .addOption('watchdog', { description: 'client connection timeout', arguments: { period: 'number', }, + // TODO(meyer) verify + returnValue: {}, }); +type CommandConfigs = { [K in keyof typeof api['options']]: typeof api['options'][K] }; + export const paramsByCommandName = api.getParamsByCommandName(); -export type CommandName = keyof typeof paramsByCommandName; +export type CommandName = keyof CommandConfigs; + +export type CommandParamsByCommandName = { + [K in CommandName]: ArgsTypes>; +}; + +export type CommandResponsesByCommandName = { + [K in CommandName]: ArgsTypes>; +}; export function assertValidCommandName(value: any): asserts value is CommandName { invariant( diff --git a/src/formatClipsGetResponse.ts b/src/formatClipsGetResponse.ts index 31c6a5d..7976390 100644 --- a/src/formatClipsGetResponse.ts +++ b/src/formatClipsGetResponse.ts @@ -1,8 +1,14 @@ -import * as ResponseInterface from './types/ResponseInterface'; +import { CommandResponsesByCommandName } from './api'; export const formatClipsGetResponse = ( - res: ResponseInterface.ClipsGet + res: CommandResponsesByCommandName['clips get'] ): Record => { + if (!res.clips) { + return { + clipsCount: 0, + }; + } + const clipsCount = res.clips.length; const response: Record = { diff --git a/src/index.ts b/src/index.ts index 85f3b79..0260fa1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,2 @@ -export * from './HyperDeckServer'; -export * from './Timecode'; -export * as ResponseInterface from './types/ResponseInterface'; +export { HyperDeckServer } from './HyperDeckServer'; +export { Timecode } from './Timecode'; diff --git a/src/messageForCode.ts b/src/messageForCode.ts index 5fbe987..bee40cf 100644 --- a/src/messageForCode.ts +++ b/src/messageForCode.ts @@ -1,7 +1,8 @@ import { CRLF } from './constants'; -import { ResponseCode, responseNamesByCode } from './types'; +import { ResponseCode, responseNamesByCode, TypesByStringKey } from './types'; import { invariant } from './invariant'; import { camelcaseToSpaceCase } from './utils'; +import { Timecode } from './Timecode'; // escape CR/LF and remove colons const sanitiseMessage = (input: string): string => { @@ -11,7 +12,7 @@ const sanitiseMessage = (input: string): string => { /** For a given code, generate the response message that will be sent to the ATEM */ export const messageForCode = ( code: ResponseCode, - params?: Record | string + params?: Record | string ): string => { if (typeof params === 'string') { return code + ' ' + sanitiseMessage(params) + CRLF; @@ -43,8 +44,15 @@ export const messageForCode = ( valueString = value ? 'true' : 'false'; } else if (typeof value === 'number') { valueString = value.toString(); + } else if (value instanceof Timecode) { + valueString = value.toString(); } else { - invariant(false, 'Unhandled value type: `%s`', typeof value); + invariant( + false, + 'Unhandled value type for key `%s`: `%s`', + key, + Array.isArray(value) ? 'array' : typeof value + ); } // convert camelCase keys to space-separated words diff --git a/src/types.ts b/src/types.ts index 2f1ee13..422337a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import { invariant } from './invariant'; import { Timecode } from './Timecode'; +import type { CommandName, CommandParamsByCommandName, CommandResponsesByCommandName } from './api'; export interface NotificationConfig { transport: boolean; @@ -8,11 +9,28 @@ export interface NotificationConfig { configuration: boolean; } -export interface DeserializedCommand { - raw: string; - name: string; - parameters: Record; -} +export type DeserializedCommandsByName = { + [K in CommandName]: { + raw: string; + name: K; + parameters: CommandParamsByCommandName[K]; + }; +}; + +export type DeserializedCommand = DeserializedCommandsByName[CommandName]; + +export type CommandHandler = ( + cmd: DeserializedCommandsByName[T]['parameters'] +) => Promise; + +export type ResponsesByCommandName = { + [K in CommandName]: { + name: K; + response: CommandResponsesByCommandName[K]; + }; +}; + +export type CommandResponse = ResponsesByCommandName[CommandName]; export type ResponseCode = ErrorCode | SynchronousCode | AsynchronousCode; @@ -139,6 +157,24 @@ export const videoFormats = { '4Kp60': true, }; +export interface ClipV1 { + name: string; + startT: Timecode; + duration: Timecode; +} + +export const isClipV1 = (value: any): value is ClipV1 => { + return typeof value === 'object' && value !== null && typeof value.name === 'string'; +}; + +export interface ClipV2 { + startT: Timecode; + duration: number; + inT: Timecode; + outT: Timecode; + name: string; +} + export type VideoFormat = keyof typeof videoFormats; export const isVideoFormat = (value: any): value is VideoFormat => { @@ -246,6 +282,10 @@ export type FileFormat = export type ArgKey = keyof TypesByStringKey; +export type ArgsTypes> = { + [K in keyof T]?: TypesByStringKey[T[K]]; +}; + export interface TypesByStringKey { boolean: boolean; string: string; @@ -260,21 +300,40 @@ export interface TypesByStringKey { audiocodec: AudioCodec; timecodeinput: TimecodeInput; recordtrigger: RecordTrigger; + clips: ClipV1[]; + slotstatus: SlotStatus; + transportstatus: TransportStatus; } +function assertArrayOf( + predicate: (v: any) => v is T, + value: any, + message: string +): asserts value is T[] { + invariant(Array.isArray(value), 'Expected an array'); + for (const item of value) { + invariant(predicate(item), message); + } +} + +const getStringOrThrow = (value: any): string => { + invariant(typeof value === 'string', 'Expected a string'); + return value; +}; + export const stringToValueFns: { /** Coerce string to the correct type or throw if the string cannot be converted. */ - [K in keyof TypesByStringKey]: (value: string) => TypesByStringKey[K]; + [K in keyof TypesByStringKey]: (value: unknown) => TypesByStringKey[K]; } = { boolean: (value) => { if (value === 'true') return true; if (value === 'false') return false; invariant(false, 'Unsupported value `%o` passed to `boolean`', value); }, - string: (value) => value, - timecode: (value) => Timecode.toTimecode(value), + string: getStringOrThrow, + timecode: (value) => Timecode.toTimecode(getStringOrThrow(value)), number: (value) => { - const valueNum = parseFloat(value); + const valueNum = parseFloat(getStringOrThrow(value)); invariant(!isNaN(valueNum), 'valueNum `%o` is NaN', value); return valueNum; }, @@ -290,12 +349,12 @@ export const stringToValueFns: { if (value === 'start' || value === 'end') { return value; } - const valueNum = parseInt(value, 10); + const valueNum = parseInt(getStringOrThrow(value), 10); if (!isNaN(valueNum)) { return valueNum; } // TODO(meyer) validate further - return value; + return getStringOrThrow(value); }, videoinput: (value) => { invariant(isVideoInput(value), 'Unsupported video input: `%o`', value); @@ -305,7 +364,7 @@ export const stringToValueFns: { invariant(isAudioInput(value), 'Unsupported audio input: `%o`', value); return value; }, - fileformat: (value) => value, + fileformat: getStringOrThrow, audiocodec: (value) => { invariant(isAudioCodec(value), 'Unsupported audio codec: `%o`', value); return value; @@ -318,4 +377,16 @@ export const stringToValueFns: { invariant(isRecordTrigger(value), 'Unsupported record trigger: `%o`', value); return value; }, + clips: (value) => { + assertArrayOf(isClipV1, value, 'Expected an array of clips'); + return value; + }, + slotstatus: (value) => { + invariant(isSlotStatus(value), 'Unsupported slot status: `%o`', value); + return value; + }, + transportstatus: (value) => { + invariant(isTransportStatus(value), 'Unsupported slot status: `%o`', value); + return value; + }, }; diff --git a/src/types/DeserializedCommands.ts b/src/types/DeserializedCommands.ts deleted file mode 100644 index f5d362d..0000000 --- a/src/types/DeserializedCommands.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { DeserializedCommand } from '../types'; - -export interface PreviewCommand extends DeserializedCommand { - parameters: { - 'disk id'?: string; - }; -} - -export interface PlayCommand extends DeserializedCommand { - parameters: { - speed?: string; - loop?: string; - 'single clip'?: string; - }; -} - -export interface PlayrangeSetCommand extends DeserializedCommand { - parameters: { - 'clip id'?: string; - in?: string; - out?: string; - }; -} - -export interface RecordCommand extends DeserializedCommand { - parameters: { - name?: string; - }; -} - -export interface ClipsGetCommand extends DeserializedCommand { - parameters: { - 'clip id'?: string; - count?: string; - }; -} - -export interface ClipsAddCommand extends DeserializedCommand { - parameters: { - name?: string; - }; -} - -export interface SlotInfoCommand extends DeserializedCommand { - parameters: { - 'slot id'?: string; - }; -} - -export interface SlotSelectCommand extends DeserializedCommand { - parameters: { - 'slot id'?: string; - 'video format'?: string; - }; -} - -export interface NotifyCommand extends DeserializedCommand { - parameters: { - remote?: string; - transport?: string; - slot?: string; - configuration?: string; - 'dropped frames'?: string; - }; -} - -export interface GoToCommand extends DeserializedCommand { - parameters: { - 'clip id'?: string; - clip?: string; - timeline?: string; - timecode?: string; - 'slot id'?: string; - }; -} - -export interface JogCommand extends DeserializedCommand { - parameters: { - timecode?: string; - }; -} - -export interface ShuttleCommand extends DeserializedCommand { - parameters: { - speed?: string; - }; -} - -export interface RemoteCommand extends DeserializedCommand { - parameters: { - remote?: string; - }; -} - -export interface ConfigurationCommand extends DeserializedCommand { - parameters: { - 'video input'?: string; - 'audio input'?: string; - 'file format'?: string; - }; -} - -export interface FormatCommand extends DeserializedCommand { - parameters: { - prepare?: string; - confirm?: string; - }; -} - -export interface IdentifyCommand extends DeserializedCommand { - parameters: { - enable?: string; - }; -} - -export interface WatchdogCommand extends DeserializedCommand { - parameters: { - period?: string; - }; -} diff --git a/src/types/ResponseInterface.ts b/src/types/ResponseInterface.ts deleted file mode 100644 index f98e03e..0000000 --- a/src/types/ResponseInterface.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { Timecode } from '../Timecode'; -import type { - TransportStatus, - VideoFormat, - SlotStatus, - AudioInput, - VideoInput, - FileFormat, -} from '../types'; - -export interface DeviceInfo { - 'protocol version': string; - model: string; - 'slot count': string; -} - -export interface DiskList extends Record { - 'slot id': string; -} - -export interface ClipsCount { - 'clip count': string; -} - -export interface ClipV1 { - name: string; - startT: Timecode; - duration: Timecode; -} - -export interface ClipV2 { - startT: Timecode; - duration: number; - inT: Timecode; - outT: Timecode; - name: string; -} - -export interface ClipsGet { - clips: ClipV1[]; -} - -export interface TransportInfo { - status: TransportStatus; - speed: string; - 'slot id': string; - 'clip id': string; - 'single clip': string; - 'display timecode': string; - timecode: string; - 'video format': VideoFormat; - loop: string; -} - -export interface SlotInfo { - 'slot id': string; - status: SlotStatus; - 'volume name': string; - 'recording time': string; - 'video format': VideoFormat; -} - -export interface Configuration { - 'audio input': AudioInput; - 'video input': VideoInput; - 'file format': FileFormat; -} - -export interface Uptime { - uptime: string; -} - -export interface Format { - token: string; -} - -export interface RemoteInfoResponse { - enabled: boolean; - override: boolean; -} diff --git a/yarn.lock b/yarn.lock index 9ff62d5..cf8a45c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -717,7 +717,7 @@ core-js "^2.6.5" regenerator-runtime "^0.13.4" -"@babel/preset-env@^7.4.4": +"@babel/preset-env@^7.10.3", "@babel/preset-env@^7.4.4": version "7.10.3" resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.10.3.tgz#3e58c9861bbd93b6a679987c7e4bd365c56c80c9" integrity sha512-jHaSUgiewTmly88bJtMHbOd1bJf2ocYxb5BWKSDQIP5tmgFuS/n0gl+nhSrYDhT33m0vPxp+rP8oYYgPgMNQlg==