@meyer/hyperdeck-emulator
Version:
Typescript Node.js library for emulating a Blackmagic Hyperdeck
1,234 lines (1,102 loc) • 37.2 kB
JavaScript
'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