timeline-state-resolver
Version:
Have timeline, control stuff
341 lines • 15.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ConnectionManager = void 0;
const timeline_state_resolver_types_1 = require("timeline-state-resolver-types");
const remoteDeviceInstance_1 = require("./remoteDeviceInstance");
const _ = require("underscore");
const deviceContainer_1 = require("..//devices/deviceContainer");
const atemUtil_1 = require("atem-connection/dist/lib/atemUtil");
const eventemitter3_1 = require("eventemitter3");
const lib_1 = require("../lib");
const FREEZE_LIMIT = 5000; // how long to wait before considering the child to be unresponsive
class ConnectionManager extends eventemitter3_1.EventEmitter {
constructor() {
super(...arguments);
this._config = new Map();
this._connections = new Map();
this._updating = false;
this._connectionAttempts = new Map();
}
/**
* Set the config options for all connections
*/
setConnections(connectionsConfig) {
// run through and see if we need to reset any of the counters
this._config.forEach((conf, id) => {
const newConf = connectionsConfig[id];
if (newConf && configHasChanged(conf, newConf)) {
// new conf warrants an immediate retry
this._connectionAttempts.delete(id);
}
});
this._config = new Map(Object.entries(connectionsConfig));
this._updateConnections();
}
getConnections(includeUninitialized = false) {
if (includeUninitialized) {
return Array.from(this._connections.values());
}
else {
return Array.from(this._connections.values()).filter((conn) => conn.initialized === true);
}
}
getConnection(connectionId, includeUninitialized = false) {
if (includeUninitialized) {
return this._connections.get(connectionId);
}
else {
const connection = this._connections.get(connectionId);
if (connection?.initialized === true) {
return connection;
}
else {
return undefined;
}
}
}
/**
* Iterate over config and check that the existing connection has the right config, if
* not... recreate it
*/
_updateConnections() {
if (this._updating)
return;
this._updating = true;
if (this._nextAttempt) {
clearTimeout(this._nextAttempt);
this._nextAttempt = undefined;
}
const operations = [];
for (const [deviceId, config] of this._config.entries()) {
// find connection
const connection = this._connections.get(deviceId);
if (connection) {
// see if it should be restarted because of an update
if (connectionConfigHasChanged(connection, config)) {
operations.push({ operation: 'update', id: deviceId });
}
else if (connection.deviceOptions.debug !== config.debug ||
connection.deviceOptions.debugState !== config.debugState) {
// see if we should set the debug params
operations.push({ operation: 'setDebug', id: deviceId });
}
}
else {
// create
operations.push({ operation: 'create', id: deviceId });
}
}
for (const deviceId of this._connections.keys()) {
// find if still in config
const config = this._config.get(deviceId);
if (!config) {
// not found, so it should be closed
operations.push({ operation: 'delete', id: deviceId });
}
}
const isAllowedOp = (op) => {
if (op.operation !== 'create')
return true; // allow non-create ops
const nextCreate = this._connectionAttempts.get(op.id);
if (!nextCreate || nextCreate.next < Date.now())
return true;
return false;
};
const allowedOperations = operations.filter(isAllowedOp);
if (operations.length === 0) {
// no operations needed, so stop the loop
this._updating = false;
return;
}
else if (allowedOperations.length === 0) {
this._updating = false;
// wait until next
const nextTime = Array.from(this._connectionAttempts.values()).reduce((a, b) => (a.next < b.next ? a : b), {
last: Date.now(),
next: Date.now() + 4000, // in 4 seconds
});
this._nextAttempt = setTimeout(() => {
this._updateConnections();
}, nextTime.next - Date.now());
// there's nothing to execute right now so return
return;
}
Promise.allSettled(allowedOperations.map(async (op) => this.executeOperation(op)))
.then(() => {
this._updating = false;
// rerun the algorithm once to make sure we have no missed operations in the meanwhile
this._updateConnections();
})
.catch((e) => {
this.emit('warning', 'Error encountered while updating connections: ' + e);
});
}
async executeOperation({ operation, id }) {
try {
switch (operation) {
case 'create':
await this.createConnection(id);
break;
case 'delete':
await this.deleteConnection(id);
break;
case 'update':
await this.deleteConnection(id);
await this.createConnection(id);
break;
case 'setDebug':
await this.setDebugForConnection(id);
break;
}
}
catch {
this.emit('warning', `Failed to execute "${operation} for ${id}"`);
}
}
async createConnection(id) {
const deviceOptions = this._config.get(id);
if (!deviceOptions)
return; // has been removed since, so do not create
const lastAttempt = this._connectionAttempts.get(id);
const last = lastAttempt?.last ?? Date.now();
this._connectionAttempts.set(id, {
last: Date.now(),
next: Date.now() + Math.min(Math.max(Date.now() - last, 2000) * 2, 60 * 1000),
}); // first retry after 4secs, double it every try, max 60s
const threadedClassOptions = {
threadUsage: deviceOptions.threadUsage || 1,
autoRestart: false,
disableMultithreading: !deviceOptions.isMultiThreaded,
instanceName: id,
freezeLimit: FREEZE_LIMIT,
};
const container = await createContainer(deviceOptions, id, () => Date.now(), threadedClassOptions); // we rely on threadedclass to timeout if this fails
if (!container) {
this.emit('warning', 'Failed to create container for ' + id);
return;
}
// set up event handlers
await this._setupDeviceListeners(id, container);
container.onChildClose = () => {
this.emit('error', 'Connection ' + id + ' closed');
this._connections.delete(id);
this.emit('connectionRemoved', id);
container
.terminate()
.catch((e) => this.emit('warning', `Failed to initialise ${id} (${e})`))
.finally(() => {
this._updateConnections();
});
};
this._connections.set(id, container);
this.emit('connectionAdded', id, container);
// trigger connection init
this._handleConnectionInitialisation(id, container)
.then(() => {
this._connectionAttempts.delete(id);
this.emit('connectionInitialised', id);
})
.catch((e) => {
this.emit('error', 'Connection ' + id + ' failed to initialise', e);
this._connections.delete(id);
this.emit('connectionRemoved', id);
container
.terminate()
.catch((e) => this.emit('warning', `Failed to initialise ${id} (${e})`))
.finally(() => {
this._updateConnections();
});
});
}
async deleteConnection(id) {
const connection = this._connections.get(id);
if (!connection)
return Promise.resolve(); // already removed / never existed
this._connections.delete(id);
this.emit('connectionRemoved', id);
return new Promise((resolve) => (0, lib_1.deferAsync)(async () => {
let finished = false;
setTimeout(() => {
if (!finished) {
resolve();
this.emit('warning', 'Failed to delete connection in time');
connection.terminate().catch((e) => this.emit('error', 'Failed to terminate connection: ' + e));
}
}, 30000);
try {
await connection.device.terminate();
await connection.device.removeAllListeners();
await connection.terminate();
}
catch {
await connection.terminate();
}
finished = true;
resolve();
}, (e) => {
this.emit('warning', 'Error encountered trying to delete connection: ' + e);
}));
}
async setDebugForConnection(id) {
const config = this._config.get(id);
const connection = this._connections.get(id);
if (!connection || !config)
return;
try {
await connection.device.setDebugLogging(config.debug ?? false);
await connection.device.setDebugState(config.debugState ?? false);
}
catch {
this.emit('warning', 'Failed to update debug values for ' + id);
}
}
async _handleConnectionInitialisation(id, container) {
const deviceOptions = this._config.get(id);
if (!deviceOptions)
return; // if the config has been removed, the connection should be removed as well so no need to init
this.emit('info', `Initializing connection ${id} (${container.instanceId}) of type ${timeline_state_resolver_types_1.DeviceType[deviceOptions.type]}...`);
await container.init(deviceOptions.options, undefined);
await container.reloadProps();
this.emit('info', `Connection ${id} (${container.instanceId}) initialized!`);
}
async _setupDeviceListeners(id, container) {
const passEvent = (ev) => {
const evHandler = (...args) => this.emit(('connectionEvent:' + ev), id, ...args);
container.device
.on(ev, evHandler)
.catch((e) => this.emit('error', 'Failed to attach listener for device: ' + id + ' ' + ev, e));
};
passEvent('info');
passEvent('warning');
passEvent('error');
passEvent('debug');
passEvent('debugState');
passEvent('connectionChanged');
passEvent('resetResolver');
passEvent('slowCommand');
passEvent('slowSentCommand');
passEvent('slowFulfilledCommand');
passEvent('commandReport');
passEvent('commandError');
passEvent('updateMediaObject');
passEvent('clearMediaObjects');
passEvent('timeTrace');
}
}
exports.ConnectionManager = ConnectionManager;
/**
* A config has changed if any of the options are no longer the same, taking default values into
* consideration. In addition, the debug logging flag should be ignored as that can be changed at runtime.
*/
function connectionConfigHasChanged(connection, config) {
const oldConfig = connection.deviceOptions;
// now check device specific options
return configHasChanged(oldConfig, config);
}
function configHasChanged(oldConfig, config) {
// now check device specific options
return !_.isEqual(_.omit(oldConfig, 'debug', 'debugState'), _.omit(config, 'debug', 'debugState'));
}
function createContainer(deviceOptions, deviceId, getCurrentTime, threadedClassOptions) {
switch (deviceOptions.type) {
case timeline_state_resolver_types_1.DeviceType.CASPARCG:
return deviceContainer_1.DeviceContainer.create('../../dist/integrations/casparCG/index.js', 'CasparCGDevice', deviceId, deviceOptions, getCurrentTime, threadedClassOptions);
case timeline_state_resolver_types_1.DeviceType.SISYFOS:
return deviceContainer_1.DeviceContainer.create('../../dist/integrations/sisyfos/index.js', 'SisyfosMessageDevice', deviceId, deviceOptions, getCurrentTime, threadedClassOptions);
case timeline_state_resolver_types_1.DeviceType.VIZMSE:
return deviceContainer_1.DeviceContainer.create('../../dist/integrations/vizMSE/index.js', 'VizMSEDevice', deviceId, deviceOptions, getCurrentTime, threadedClassOptions);
case timeline_state_resolver_types_1.DeviceType.VMIX:
return deviceContainer_1.DeviceContainer.create('../../dist/integrations/vmix/index.js', 'VMixDevice', deviceId, deviceOptions, getCurrentTime, threadedClassOptions);
case timeline_state_resolver_types_1.DeviceType.SINGULAR_LIVE:
case timeline_state_resolver_types_1.DeviceType.TELEMETRICS:
case timeline_state_resolver_types_1.DeviceType.PHAROS:
case timeline_state_resolver_types_1.DeviceType.ABSTRACT:
case timeline_state_resolver_types_1.DeviceType.ATEM:
case timeline_state_resolver_types_1.DeviceType.HTTPSEND:
case timeline_state_resolver_types_1.DeviceType.HTTPWATCHER:
case timeline_state_resolver_types_1.DeviceType.HYPERDECK:
case timeline_state_resolver_types_1.DeviceType.LAWO:
case timeline_state_resolver_types_1.DeviceType.MULTI_OSC:
case timeline_state_resolver_types_1.DeviceType.OBS:
case timeline_state_resolver_types_1.DeviceType.OSC:
case timeline_state_resolver_types_1.DeviceType.PANASONIC_PTZ:
case timeline_state_resolver_types_1.DeviceType.SHOTOKU:
case timeline_state_resolver_types_1.DeviceType.SOFIE_CHEF:
case timeline_state_resolver_types_1.DeviceType.TCPSEND:
case timeline_state_resolver_types_1.DeviceType.TRICASTER:
case timeline_state_resolver_types_1.DeviceType.VISCA_OVER_IP:
case timeline_state_resolver_types_1.DeviceType.QUANTEL: {
ensureIsImplementedAsService(deviceOptions.type);
// presumably this device is implemented in the new service handler
return remoteDeviceInstance_1.RemoteDeviceInstance.create(deviceId, deviceOptions, getCurrentTime, threadedClassOptions);
}
default:
(0, atemUtil_1.assertNever)(deviceOptions);
return null;
}
}
function ensureIsImplementedAsService(_type) {
// This is a type check
}
//# sourceMappingURL=ConnectionManager.js.map