UNPKG

@meyer/hyperdeck-emulator

Version:

Typescript Node.js library for emulating a Blackmagic Hyperdeck

1,234 lines (1,102 loc) 37.2 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var events = require('events'); var util = _interopDefault(require('util')); var net = require('net'); var pino = _interopDefault(require('pino')); class FormattedError extends Error { constructor(template, ...args) { super(util.format(template, ...args)); this.template = template; this.args = args; } } function invariant(condition, message, ...args) { if (!condition) { throw new FormattedError(message, ...args); } } class Timecode { constructor(hh, mm, ss, ff) { const timecode = [hh, mm, ss, ff].map(code => { const codeInt = Math.floor(code); !(codeInt === code && code >= 0 && code <= 99) ? invariant(false, 'Timecode params must be an integer between 0 and 99') : void 0; // turn the integer into a potentially zero-prefixed string return (codeInt + 100).toString().slice(-2); }).join(':'); this.toString = () => timecode; } } Timecode.toTimecode = tcString => { const bits = tcString.split(':'); !(bits.length === 4) ? invariant(false, 'Expected 4 bits, received %o bits', bits.length) : void 0; const bitsInt = bits.map(bit => { const bitInt = parseInt(bit, 10); !!isNaN(bitInt) ? invariant(false, 'bit `%s` is NaN', bit) : void 0; return bitInt; }); return new Timecode(bitsInt[0], bitsInt[1], bitsInt[2], bitsInt[3]); }; var ErrorCode; (function (ErrorCode) { ErrorCode[ErrorCode["SyntaxError"] = 100] = "SyntaxError"; ErrorCode[ErrorCode["UnsupportedParameter"] = 101] = "UnsupportedParameter"; ErrorCode[ErrorCode["InvalidValue"] = 102] = "InvalidValue"; ErrorCode[ErrorCode["Unsupported"] = 103] = "Unsupported"; ErrorCode[ErrorCode["DiskFull"] = 104] = "DiskFull"; ErrorCode[ErrorCode["NoDisk"] = 105] = "NoDisk"; ErrorCode[ErrorCode["DiskError"] = 106] = "DiskError"; ErrorCode[ErrorCode["TimelineEmpty"] = 107] = "TimelineEmpty"; ErrorCode[ErrorCode["InternalError"] = 108] = "InternalError"; ErrorCode[ErrorCode["OutOfRange"] = 109] = "OutOfRange"; ErrorCode[ErrorCode["NoInput"] = 110] = "NoInput"; ErrorCode[ErrorCode["RemoteControlDisabled"] = 111] = "RemoteControlDisabled"; ErrorCode[ErrorCode["ConnectionRejected"] = 120] = "ConnectionRejected"; ErrorCode[ErrorCode["InvalidState"] = 150] = "InvalidState"; ErrorCode[ErrorCode["InvalidCodec"] = 151] = "InvalidCodec"; ErrorCode[ErrorCode["InvalidFormat"] = 160] = "InvalidFormat"; ErrorCode[ErrorCode["InvalidToken"] = 161] = "InvalidToken"; ErrorCode[ErrorCode["FormatNotPrepared"] = 162] = "FormatNotPrepared"; })(ErrorCode || (ErrorCode = {})); var SynchronousCode; (function (SynchronousCode) { SynchronousCode[SynchronousCode["OK"] = 200] = "OK"; SynchronousCode[SynchronousCode["SlotInfo"] = 202] = "SlotInfo"; SynchronousCode[SynchronousCode["DeviceInfo"] = 204] = "DeviceInfo"; SynchronousCode[SynchronousCode["ClipsInfo"] = 205] = "ClipsInfo"; SynchronousCode[SynchronousCode["DiskList"] = 206] = "DiskList"; SynchronousCode[SynchronousCode["TransportInfo"] = 208] = "TransportInfo"; SynchronousCode[SynchronousCode["Notify"] = 209] = "Notify"; SynchronousCode[SynchronousCode["Remote"] = 210] = "Remote"; SynchronousCode[SynchronousCode["Configuration"] = 211] = "Configuration"; SynchronousCode[SynchronousCode["ClipsCount"] = 214] = "ClipsCount"; SynchronousCode[SynchronousCode["Uptime"] = 215] = "Uptime"; SynchronousCode[SynchronousCode["FormatReady"] = 216] = "FormatReady"; })(SynchronousCode || (SynchronousCode = {})); var AsynchronousCode; (function (AsynchronousCode) { AsynchronousCode[AsynchronousCode["ConnectionInfo"] = 500] = "ConnectionInfo"; AsynchronousCode[AsynchronousCode["SlotInfo"] = 502] = "SlotInfo"; AsynchronousCode[AsynchronousCode["TransportInfo"] = 508] = "TransportInfo"; AsynchronousCode[AsynchronousCode["RemoteInfo"] = 510] = "RemoteInfo"; AsynchronousCode[AsynchronousCode["ConfigurationInfo"] = 511] = "ConfigurationInfo"; })(AsynchronousCode || (AsynchronousCode = {})); const responseNamesByCode = { [AsynchronousCode.ConfigurationInfo]: 'configuration info', [AsynchronousCode.ConnectionInfo]: 'connection info', [AsynchronousCode.RemoteInfo]: 'remote info', [AsynchronousCode.SlotInfo]: 'slot info', [AsynchronousCode.TransportInfo]: 'transport info', [ErrorCode.ConnectionRejected]: 'connection rejected', [ErrorCode.DiskError]: 'disk error', [ErrorCode.DiskFull]: 'disk full', [ErrorCode.FormatNotPrepared]: 'format not prepared', [ErrorCode.InternalError]: 'internal error', [ErrorCode.InvalidCodec]: 'invalid codec', [ErrorCode.InvalidFormat]: 'invalid format', [ErrorCode.InvalidState]: 'invalid state', [ErrorCode.InvalidToken]: 'invalid token', [ErrorCode.InvalidValue]: 'invalid value', [ErrorCode.NoDisk]: 'no disk', [ErrorCode.NoInput]: 'no input', [ErrorCode.OutOfRange]: 'out of range', [ErrorCode.RemoteControlDisabled]: 'remote control disabled', [ErrorCode.SyntaxError]: 'syntax error', [ErrorCode.TimelineEmpty]: 'timeline empty', [ErrorCode.Unsupported]: 'unsupported', [ErrorCode.UnsupportedParameter]: 'unsupported parameter', [SynchronousCode.ClipsCount]: 'clips count', [SynchronousCode.ClipsInfo]: 'clips info', [SynchronousCode.Configuration]: 'configuration', [SynchronousCode.DeviceInfo]: 'device info', [SynchronousCode.DiskList]: 'disk list', [SynchronousCode.FormatReady]: 'format ready', [SynchronousCode.Notify]: 'notify', [SynchronousCode.OK]: 'ok', [SynchronousCode.Remote]: 'remote', [SynchronousCode.SlotInfo]: 'slot info', [SynchronousCode.TransportInfo]: 'transport info', [SynchronousCode.Uptime]: 'uptime' }; const slotStatus = { empty: true, mounting: true, error: true, mounted: true }; const isSlotStatus = value => { return typeof value === 'string' && slotStatus.hasOwnProperty(value); }; const videoFormats = { NTSC: true, PAL: true, NTSCp: true, PALp: true, '720p50': true, '720p5994': true, '720p60': true, '1080p23976': true, '1080p24': true, '1080p25': true, '1080p2997': true, '1080p30': true, '1080i50': true, '1080i5994': true, '1080i60': true, '4Kp23976': true, '4Kp24': true, '4Kp25': true, '4Kp2997': true, '4Kp30': true, '4Kp50': true, '4Kp5994': true, '4Kp60': true }; const isClipV1 = value => { return typeof value === 'object' && value !== null && typeof value.name === 'string'; }; const isVideoFormat = value => { return typeof value === 'string' && videoFormats.hasOwnProperty(value); }; const transportStatus = { preview: true, stopped: true, play: true, forward: true, rewind: true, jog: true, shuttle: true, record: true }; const isTransportStatus = value => { return typeof value === 'string' && transportStatus.hasOwnProperty(value); }; const stopModes = { lastframe: true, nextframe: true, black: true }; const isStopMode = value => { return typeof value === 'string' && stopModes.hasOwnProperty(value); }; const videoInputs = { SDI: true, HDMI: true, component: true }; const isVideoInput = value => { return typeof value === 'string' && videoInputs.hasOwnProperty(value); }; const audioInputs = { XLR: true, RCA: true, // TODO(meyer) verify this embedded: true }; const isAudioInput = value => { return typeof value === 'string' && audioInputs.hasOwnProperty(value); }; const audioCodecs = { PCM: true, AAC: true }; const isAudioCodec = value => { return typeof value === 'string' && audioCodecs.hasOwnProperty(value); }; const timecodeInputs = { external: true, embedded: true, preset: true, clip: true }; const isTimecodeInput = value => { return typeof value === 'string' && timecodeInputs.hasOwnProperty(value); }; const recordTriggers = { none: true, recordbit: true, timecoderun: true }; const isRecordTrigger = value => { return typeof value === 'string' && recordTriggers.hasOwnProperty(value); }; function assertArrayOf(predicate, value, message) { !Array.isArray(value) ? invariant(false, 'Expected an array') : void 0; for (const item of value) { !predicate(item) ? invariant(false, message) : void 0; } } const getStringOrThrow = value => { !(typeof value === 'string') ? invariant(false, 'Expected a string') : void 0; return value; }; const stringToValueFns = { boolean: value => { if (value === 'true') return true; if (value === 'false') return false; invariant(false, 'Unsupported value `%o` passed to `boolean`', value) ; }, string: getStringOrThrow, timecode: value => Timecode.toTimecode(getStringOrThrow(value)), number: value => { const valueNum = parseFloat(getStringOrThrow(value)); !!isNaN(valueNum) ? invariant(false, 'valueNum `%o` is NaN', value) : void 0; return valueNum; }, videoformat: value => { !isVideoFormat(value) ? invariant(false, 'Unsupported video format: `%o`') : void 0; return value; }, stopmode: value => { !isStopMode(value) ? invariant(false, 'Unsupported stopmode: `%o`', value) : void 0; return value; }, goto: value => { if (value === 'start' || value === 'end') { return value; } const valueNum = parseInt(getStringOrThrow(value), 10); if (!isNaN(valueNum)) { return valueNum; } // TODO(meyer) validate further return getStringOrThrow(value); }, videoinput: value => { !isVideoInput(value) ? invariant(false, 'Unsupported video input: `%o`', value) : void 0; return value; }, audioinput: value => { !isAudioInput(value) ? invariant(false, 'Unsupported audio input: `%o`', value) : void 0; return value; }, fileformat: getStringOrThrow, audiocodec: value => { !isAudioCodec(value) ? invariant(false, 'Unsupported audio codec: `%o`', value) : void 0; return value; }, timecodeinput: value => { !isTimecodeInput(value) ? invariant(false, 'Unsupported timecode input: `%o`', value) : void 0; return value; }, recordtrigger: value => { !isRecordTrigger(value) ? invariant(false, 'Unsupported record trigger: `%o`', value) : void 0; return value; }, clips: value => { assertArrayOf(isClipV1, value, 'Expected an array of clips'); return value; }, slotstatus: value => { !isSlotStatus(value) ? invariant(false, 'Unsupported slot status: `%o`', value) : void 0; return value; }, transportstatus: value => { !isTransportStatus(value) ? invariant(false, 'Unsupported slot status: `%o`', value) : void 0; return value; } }; const CRLF = '\r\n'; /** Convert `yourExampleKey` to `your example key` */ const camelcaseToSpaceCase = key => { return key.replace(/([a-z])([A-Z]+)/g, '$1 $2').toLowerCase(); }; /** Internal container class that holds metadata about each HyperDeck event */ class HyperDeckAPI { constructor( // public only because TS apparently strips types from private methods options = {}) { this.options = options; this.addOption = (key, option) => { const k = Array.isArray(key) ? key[0] : key; !!this.options.hasOwnProperty(k) ? invariant(false, 'option already exists for key `%s`', k) : void 0; // NOTE: this mutates the original options object // shouldn't be a problem since this is only used internally Object.assign(this.options, { [k]: option }); return this; }; /** Get a `Set` of param names keyed by function name */ this.getParamsByCommandName = () => Object.entries(this.options).reduce((prev, [commandName, value]) => { if (!value.arguments) { // we still want hasOwnProperty(key) to be true prev[commandName] = {}; return prev; } prev[commandName] = Object.entries(value.arguments).reduce((argObj, [argKey, argType]) => { argObj[camelcaseToSpaceCase(argKey)] = { paramType: argType, paramName: argKey }; return argObj; }, {}); return prev; }, {}); } } const api = /*#__PURE__*/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' } }).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', arguments: { speed: 'number', 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', arguments: { // maybe number? clipId: 'number', // description: 'set play range to play between timecode {inT} and timecode {outT}', in: 'timecode', out: 'timecode', // 'set play range in units of frames between timeline position {in} and position {out} clear/reset play range°setting', 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', // description: 'enable or disable play on startup', arguments: { 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', arguments: { clipId: 'number', count: 'number', version: 'number' }, returnValue: { clips: 'clips' } }).addOption('clips add', { description: 'append a clip to timeline', arguments: { name: 'string', clipId: 'number', 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', arguments: { 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', arguments: { // TODO(meyer) is this correct? playbackOverride: 'string' }, // TODO(meyer) returnValue: {} }).addOption('notify', { description: 'query notification status', arguments: { remote: 'boolean', transport: 'boolean', slot: 'boolean', configuration: 'boolean', droppedFrames: 'boolean', displayTimecode: 'boolean', timelinePosition: 'boolean', 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', arguments: { clipId: 'number', clip: 'goto', timeline: 'goto', 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', arguments: { enable: 'boolean', override: 'boolean' }, // TODO(meyer) returnValue: {} }).addOption('configuration', { description: 'query configuration settings', arguments: { videoInput: 'videoinput', audioInput: 'audioinput', fileFormat: 'fileformat', audioCodec: 'audiocodec', timecodeInput: 'timecodeinput', timecodePreset: 'timecode', audioInputChannels: 'number', recordTrigger: 'recordtrigger', 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}', arguments: { 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: {} }); const paramsByCommandName = /*#__PURE__*/api.getParamsByCommandName(); function assertValidCommandName(value) { !(typeof value === 'string' && paramsByCommandName.hasOwnProperty(value)) ? invariant(false, 'Invalid command: `%o`', value) : void 0; } class MultilineParser { constructor(logger) { this.linesQueue = []; this.logger = logger.child({ name: 'MultilineParser' }); } receivedString(data) { const res = []; // add new lines to processing queue const newLines = data.split(CRLF); // remove the blank line at the end from the intentionally trailing \r\n if (newLines.length > 0 && newLines[newLines.length - 1] === '') newLines.pop(); this.linesQueue = this.linesQueue.concat(newLines); while (this.linesQueue.length > 0) { // skip any blank lines if (this.linesQueue[0] === '') { this.linesQueue.shift(); continue; } // if the first line has no colon, then it is a single line command if (!this.linesQueue[0].includes(':') || this.linesQueue.length === 1 && this.linesQueue[0].includes(':')) { const parsedResponse = this.parseResponse(this.linesQueue.splice(0, 1)); if (parsedResponse) { res.push(parsedResponse); } continue; } const endLine = this.linesQueue.indexOf(''); if (endLine === -1) { // Not got full response yet break; } const lines = this.linesQueue.splice(0, endLine + 1); const parsedResponse = this.parseResponse(lines); if (parsedResponse) { res.push(parsedResponse); } } return res; } parseResponse(responseLines) { try { const lines = responseLines.map(l => l.trim()); const firstLine = lines[0]; if (lines.length === 1) { if (!firstLine.includes(':')) { assertValidCommandName(firstLine); return { raw: lines.join(CRLF), name: firstLine, parameters: {} }; } // single-line command with params const bits = firstLine.split(': '); const commandName = bits.shift(); assertValidCommandName(commandName); const params = {}; const paramNames = paramsByCommandName[commandName]; let param = bits.shift(); !param ? "development" !== "production" ? invariant(false, 'No named parameters found') : invariant(false) : void 0; for (let i = 0; i < bits.length - 1; i++) { const bit = bits[i]; const bobs = bit.split(' '); let nextParam = ''; for (let i = bobs.length - 1; i >= 0; i--) { nextParam = (bobs.pop() + ' ' + nextParam).trim(); if (paramNames.hasOwnProperty(nextParam)) { break; } } !(bobs.length > 0) ? "development" !== "production" ? invariant(false, 'Command malformed / paramName not recognised: `%s`', bit) : invariant(false) : void 0; !paramNames.hasOwnProperty(param) ? "development" !== "production" ? invariant(false, 'Unsupported param: `%o`', param) : invariant(false) : void 0; const value = bobs.join(' '); const { paramName, paramType } = paramNames[param]; const formatter = stringToValueFns[paramType]; params[paramName] = formatter(value); param = nextParam; } !paramNames.hasOwnProperty(param) ? "development" !== "production" ? invariant(false, 'Unsupported param: `%o`', param) : invariant(false) : void 0; const value = bits[bits.length - 1]; const { paramName, paramType } = paramNames[param]; const formatter = stringToValueFns[paramType]; params[paramName] = formatter(value); return { raw: lines.join(CRLF), name: commandName, parameters: params }; } !firstLine.endsWith(':') ? "development" !== "production" ? invariant(false, 'Expected a line ending in semicolon, received `%o`', firstLine) : invariant(false) : void 0; // remove the semicolon at the end of the command const commandName = firstLine.slice(0, -1); assertValidCommandName(commandName); const paramNames = paramsByCommandName[commandName]; const params = {}; for (const line of lines) { const lineMatch = line.match(/^(.*?): (.*)$/im); !lineMatch ? "development" !== "production" ? invariant(false, 'Failed to parse line: `%o`', line) : invariant(false) : void 0; const param = lineMatch[1]; const value = lineMatch[2]; !paramNames.hasOwnProperty(param) ? "development" !== "production" ? invariant(false, 'Unsupported param: `%o`', param) : invariant(false) : void 0; const { paramName, paramType } = paramNames[param]; const formatter = stringToValueFns[paramType]; params[paramName] = formatter(value); } const res = { raw: lines.join(CRLF), name: commandName, parameters: params }; return res; } catch (err) { if (err instanceof FormattedError) { this.logger.error(err.template, ...err.args); } else { this.logger.error({ err: err + '' }, 'parseResponse error'); } return null; } } } const sanitiseMessage = input => { return input.replace(/\r/g, '\\r').replace(/\n/g, '\\n').replace(/:/g, ''); }; /** For a given code, generate the response message that will be sent to the ATEM */ const messageForCode = (code, params) => { if (typeof params === 'string') { return code + ' ' + sanitiseMessage(params) + CRLF; } const firstLine = `${code} ${responseNamesByCode[code]}`; // bail if no params if (!params) { return firstLine + CRLF; } // filter out params with null/undefined values const paramEntries = Object.entries(params).filter(([, value]) => value != null); // bail if no params after filtering if (paramEntries.length === 0) { return firstLine + CRLF; } // turn the params object into a key/value return paramEntries.reduce((prev, [key, value]) => { let valueString; if (typeof value === 'string') { valueString = value; } else if (typeof value === 'boolean') { 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 for key `%s`: `%s`', key, Array.isArray(value) ? 'array' : typeof value) ; } // convert camelCase keys to space-separated words const formattedKey = camelcaseToSpaceCase(key); return prev + formattedKey + ': ' + valueString + CRLF; }, firstLine + ':' + CRLF) + CRLF; }; class HyperDeckSocket extends events.EventEmitter { constructor(socket, logger, receivedCommand) { super(); this.socket = socket; this.logger = logger; this.receivedCommand = receivedCommand; this.lastReceivedMS = -1; this.watchdogTimer = null; this.notifySettings = { configuration: false, displayTimecode: false, droppedFrames: false, dynamicRange: false, playrange: false, remote: false, slot: false, timelinePosition: false, transport: false }; this.parser = new MultilineParser(logger); this.socket.setEncoding('utf-8'); this.socket.on('data', data => { this.onMessage(data); }); this.socket.on('error', err => { logger.info({ err }, 'error'); this.socket.destroy(); this.emit('disconnected'); logger.info('manually disconnected'); }); this.sendResponse(AsynchronousCode.ConnectionInfo, { 'protocol version': '1.11', model: 'NodeJS HyperDeck Server Library' }); } onMessage(data) { this.logger.info({ data }, '<--- received message from client'); this.lastReceivedMS = Date.now(); const cmds = this.parser.receivedString(data); this.logger.info({ cmds }, 'parsed commands'); for (const cmd of cmds) { // special cases if (cmd.name === 'watchdog') { if (this.watchdogTimer) global.clearInterval(this.watchdogTimer); const watchdogCmd = cmd; if (watchdogCmd.parameters.period) { this.watchdogTimer = global.setInterval(() => { if (Date.now() - this.lastReceivedMS > Number(watchdogCmd.parameters.period)) { this.socket.destroy(); this.emit('disconnected'); if (this.watchdogTimer) { clearInterval(this.watchdogTimer); } } }, Number(watchdogCmd.parameters.period) * 1000); } } else if (cmd.name === 'notify') { const notifyCmd = cmd; if (Object.keys(notifyCmd.parameters).length > 0) { for (const param of Object.keys(notifyCmd.parameters)) { if (this.notifySettings[param] !== undefined) { this.notifySettings[param] = notifyCmd.parameters[param] === true; } } } else { const settings = {}; for (const key of Object.keys(this.notifySettings)) { settings[key] = this.notifySettings[key] ? 'true' : 'false'; } this.sendResponse(SynchronousCode.Notify, settings, cmd); continue; } } this.receivedCommand(cmd).then(codeOrObj => { if (typeof codeOrObj === 'object') { const code = codeOrObj.code; const paramsOrMessage = 'params' in codeOrObj && codeOrObj.params || 'message' in codeOrObj && codeOrObj.message || undefined; return this.sendResponse(code, paramsOrMessage, cmd); } const code = codeOrObj; if (typeof code === 'number' && (ErrorCode[code] || SynchronousCode[code] || AsynchronousCode[code])) { return this.sendResponse(code, undefined, cmd); } this.logger.error({ cmd, codeOrObj }, 'codeOrObj was neither a ResponseCode nor a response object'); this.sendResponse(ErrorCode.InternalError, undefined, cmd); }, // not implemented by client code: () => this.sendResponse(ErrorCode.Unsupported, undefined, cmd)); } } sendResponse(code, paramsOrMessage, cmd) { try { const responseText = messageForCode(code, paramsOrMessage); const method = ErrorCode[code] ? 'error' : 'info'; this.logger[method]({ responseText, cmd }, '---> send response to client'); this.socket.write(responseText); } catch (err) { this.logger.error({ cmd }, '-x-> Error sending response: %s', err); } } notify(type, params) { this.logger.info({ type, params }, 'notify'); if (type === 'configuration' && this.notifySettings.configuration) { this.sendResponse(AsynchronousCode.ConfigurationInfo, params); } else if (type === 'remote' && this.notifySettings.remote) { this.sendResponse(AsynchronousCode.RemoteInfo, params); } else if (type === 'slot' && this.notifySettings.slot) { this.sendResponse(AsynchronousCode.SlotInfo, params); } else if (type === 'transport' && this.notifySettings.transport) { this.sendResponse(AsynchronousCode.TransportInfo, params); } else { this.logger.error({ type, params }, 'unhandled notify type'); } } } const formatClipsGetResponse = res => { if (!res.clips) { return { clipsCount: 0 }; } const clipsCount = res.clips.length; const response = { clipsCount }; for (let idx = 0; idx < clipsCount; idx++) { const clip = res.clips[idx]; const clipKey = (idx + 1).toString(); response[clipKey] = `${clip.name} ${clip.startT} ${clip.duration}`; } return response; }; class HyperDeckServer { constructor(listenOpts, logger = pino()) { const _this = this; this.sockets = {}; this.commandHandlers = {}; this.on = (key, handler) => { !paramsByCommandName.hasOwnProperty(key) ? invariant(false, 'Invalid key: `%s`', key) : void 0; !!this.commandHandlers.hasOwnProperty(key) ? invariant(false, 'Handler already registered for `%s`', key) : void 0; this.commandHandlers[key] = handler; }; this.receivedCommand = function (cmd) { try { // TODO(meyer) more sophisticated debouncing return Promise.resolve(new Promise(resolve => setTimeout(() => resolve(), 200))).then(function () { _this.logger.info({ cmd }, 'receivedCommand %s', cmd.name); if (cmd.name === 'remote') { return { code: SynchronousCode.Remote, params: { enabled: true, override: false } }; } // implemented in socket.ts if (cmd.name === 'notify' || cmd.name === 'watchdog' || cmd.name === 'ping') { return SynchronousCode.OK; } const handler = _this.commandHandlers[cmd.name]; if (!handler) { _this.logger.error({ cmd }, 'unimplemented'); return ErrorCode.Unsupported; } return Promise.resolve(handler(cmd)).then(function (response) { const result = { name: cmd.name, response }; 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 (result.name === 'device info') { return { code: SynchronousCode.DeviceInfo, params: result.response }; } if (result.name === 'disk list') { return { code: SynchronousCode.DiskList, params: result.response }; } if (result.name === 'clips count') { return { code: SynchronousCode.ClipsCount, params: result.response }; } if (result.name === 'clips get') { return { code: SynchronousCode.ClipsInfo, params: formatClipsGetResponse(result.response) }; } if (result.name === 'transport info') { return { code: SynchronousCode.TransportInfo, params: result.response }; } if (result.name === 'slot info') { return { code: SynchronousCode.SlotInfo, params: result.response }; } if (result.name === 'configuration') { if (result) { return { code: SynchronousCode.Configuration, params: result.response }; } return SynchronousCode.OK; } if (result.name === 'uptime') { return { code: SynchronousCode.Uptime, params: result.response }; } if (result.name === 'format') { if (result) { return { code: SynchronousCode.FormatReady, params: result.response }; } return SynchronousCode.OK; } _this.logger.error({ cmd, res: result }, 'Unsupported command'); return ErrorCode.Unsupported; }); }); } catch (e) { return Promise.reject(e); } }; this.logger = logger.child({ name: 'HyperDeck Emulator' }); this.server = net.createServer(socket => { this.logger.info('connection'); const socketId = Math.random().toString(35).substr(-6); const socketLogger = this.logger.child({ name: 'HyperDeck socket ' + socketId }); this.sockets[socketId] = new HyperDeckSocket(socket, socketLogger, this.receivedCommand); this.sockets[socketId].on('disconnected', () => { socketLogger.info('disconnected'); delete this.sockets[socketId]; }); }); this.server.on('listening', () => { this.logger.info('listening', { address: this.server.address() }); }); this.server.on('close', () => this.logger.info('connection closed')); this.server.on('error', err => this.logger.error('server error:', err)); this.server.maxConnections = 1; if ('ip' in listenOpts) { this.server.listen(listenOpts.port || 9993, listenOpts.ip); } else if ('fd' in listenOpts) { this.server.listen({ fd: listenOpts.fd }); } else { invariant(false, 'Invalid listen options: `%o`', listenOpts) ; } } close() { this.server.unref(); } notifySlot(params) { this.notify('slot', params); } notifyTransport(params) { this.notify('transport', params); } notify(type, params) { for (const id of Object.keys(this.sockets)) { this.sockets[id].notify(type, params); } } } exports.HyperDeckServer = HyperDeckServer; exports.Timecode = Timecode; //# sourceMappingURL=hyperdeck-emulator.cjs.development.js.map