atem-connection
Version:
Typescript Node.js library for connecting with an ATEM switcher.
347 lines • 14.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AtemSocketChild = exports.PacketFlag = exports.ConnectionState = exports.COMMAND_CONNECT_HELLO = void 0;
/**
* Note: this file wants as few imports as possible, as it gets loaded in a worker-thread and may require its own webpack bundle
*/
const dgram_1 = require("dgram");
const perf_hooks_1 = require("perf_hooks");
const IN_FLIGHT_TIMEOUT = 60; // ms
const CONNECTION_TIMEOUT = 5000; // ms
const CONNECTION_RETRY_INTERVAL = 1000; // ms
const RETRANSMIT_INTERVAL = 10; // ms
const MAX_PACKET_RETRIES = 10;
const MAX_PACKET_ID = 1 << 15; // Atem expects 15 not 16 bits before wrapping
const MAX_PACKET_PER_ACK = 16;
exports.COMMAND_CONNECT_HELLO = Buffer.from([
0x10, 0x14, 0x53, 0xab, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3a, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00,
]);
var ConnectionState;
(function (ConnectionState) {
ConnectionState[ConnectionState["Closed"] = 0] = "Closed";
ConnectionState[ConnectionState["SynSent"] = 1] = "SynSent";
ConnectionState[ConnectionState["Established"] = 2] = "Established";
/** Disconnected by the user (by calling `disconnect()`) */
ConnectionState[ConnectionState["Disconnected"] = 3] = "Disconnected";
})(ConnectionState = exports.ConnectionState || (exports.ConnectionState = {}));
var PacketFlag;
(function (PacketFlag) {
PacketFlag[PacketFlag["AckRequest"] = 1] = "AckRequest";
PacketFlag[PacketFlag["NewSessionId"] = 2] = "NewSessionId";
PacketFlag[PacketFlag["IsRetransmit"] = 4] = "IsRetransmit";
PacketFlag[PacketFlag["RetransmitRequest"] = 8] = "RetransmitRequest";
PacketFlag[PacketFlag["AckReply"] = 16] = "AckReply";
})(PacketFlag = exports.PacketFlag || (exports.PacketFlag = {}));
class AtemSocketChild {
constructor(options, onDisconnect, onLog, onCommandReceived, onCommandAcknowledged) {
this._connectionState = ConnectionState.Closed;
this._nextSendPacketId = 1;
this._sessionId = 0;
this._lastReceivedAt = perf_hooks_1.performance.now();
this._lastReceivedPacketId = 0;
this._inFlight = [];
this._receivedWithoutAck = 0;
this._debugBuffers = options.debugBuffers;
this._address = options.address;
this._port = options.port;
this.onDisconnect = onDisconnect;
this.onLog = onLog;
this.onCommandsReceived = onCommandReceived;
this.onPacketsAcknowledged = onCommandAcknowledged;
this._socket = this._createSocket();
}
startTimers() {
if (!this._reconnectTimer) {
this._reconnectTimer = setInterval(() => {
if (this._lastReceivedAt + CONNECTION_TIMEOUT > perf_hooks_1.performance.now()) {
// We heard from the atem recently
return;
}
this.restartConnection().catch((e) => {
this.log(`Reconnect failed: ${e}`);
});
}, CONNECTION_RETRY_INTERVAL);
}
}
async connect(address, port) {
this._address = address;
this._port = port;
return this.restartConnection();
}
async disconnect() {
this._clearTimers();
return this._closeSocket().then(async () => {
this._connectionState = ConnectionState.Disconnected;
return this.onDisconnect();
});
}
_clearTimers() {
if (this._retransmitTimer) {
clearTimeout(this._retransmitTimer);
this._retransmitTimer = undefined;
}
if (this._reconnectTimer) {
clearInterval(this._reconnectTimer);
this._reconnectTimer = undefined;
}
}
async restartConnection() {
this._clearTimers();
// This includes a 'disconnect'
if (this._connectionState === ConnectionState.Established) {
this._connectionState = ConnectionState.Closed;
this._recreateSocket();
await this.onDisconnect();
}
else if (this._connectionState === ConnectionState.Disconnected) {
this._createSocket();
}
// Reset connection
this._nextSendPacketId = 1;
this._sessionId = 0;
this._inFlight = [];
this.log('reconnect');
this.startTimers();
// Try doing reconnect
this._sendPacket(exports.COMMAND_CONNECT_HELLO);
this._connectionState = ConnectionState.SynSent;
}
log(message) {
// tslint:disable-next-line: no-floating-promises
void this.onLog(message);
}
sendPackets(packets) {
for (const packet of packets) {
this.sendPacket(packet.payloadLength, packet.payloadHex, packet.trackingId);
}
}
sendPacket(payloadLength, payloadHex, trackingId) {
const packetId = this._nextSendPacketId++;
if (this._nextSendPacketId >= MAX_PACKET_ID)
this._nextSendPacketId = 0;
const opcode = PacketFlag.AckRequest << 11;
const buffer = Buffer.alloc(12 + payloadLength, 0);
buffer.writeUInt16BE(opcode | (payloadLength + 12), 0); // Opcode & Length
buffer.writeUInt16BE(this._sessionId, 2);
buffer.writeUInt16BE(packetId, 10);
buffer.write(payloadHex, 12, payloadLength, 'hex');
this._sendPacket(buffer);
this._inFlight.push({
packetId,
trackingId,
lastSent: perf_hooks_1.performance.now(),
payload: buffer,
resent: 0,
});
this._triggerRetransmitTimer();
}
_recreateSocket() {
this._closeSocket().catch((_err) => {
// do nothing because it always resolves
});
return this._createSocket();
}
async _closeSocket() {
if (this._ackTimer) {
clearTimeout(this._ackTimer);
delete this._ackTimer;
}
return new Promise((resolve) => {
try {
this._socket.close(() => resolve());
}
catch (err) {
this.log(`Error closing socket: ${err}`);
resolve();
}
});
}
_createSocket() {
this._socket = (0, dgram_1.createSocket)('udp4');
this._socket.bind();
this._socket.on('message', (packet, rinfo) => this._receivePacket(packet, rinfo));
this._socket.on('error', (err) => {
this.log(`Connection error: ${err}`);
if (this._connectionState === ConnectionState.Established) {
// If connection is open, then restart. Otherwise the reconnectTimer will handle it
this.restartConnection().catch((e) => {
this.log(`Failed to restartConnection: ${e?.message ?? e}`);
});
}
});
return this._socket;
}
_isPacketCoveredByAck(ackId, packetId) {
const tolerance = MAX_PACKET_ID / 2;
const pktIsShortlyBefore = packetId < ackId && packetId + tolerance > ackId;
const pktIsShortlyAfter = packetId > ackId && packetId < ackId + tolerance;
const pktIsBeforeWrap = packetId > ackId + tolerance;
return packetId === ackId || ((pktIsShortlyBefore || pktIsBeforeWrap) && !pktIsShortlyAfter);
}
_receivePacket(packet, rinfo) {
if (this._debugBuffers)
this.log(`RECV ${packet.toString('hex')}`);
this._lastReceivedAt = perf_hooks_1.performance.now();
const length = packet.readUInt16BE(0) & 0x07ff;
if (length !== rinfo.size)
return;
const flags = packet.readUInt8(0) >> 3;
this._sessionId = packet.readUInt16BE(2);
const remotePacketId = packet.readUInt16BE(10);
// Send hello answer packet when receive connect flags
if (flags & PacketFlag.NewSessionId) {
this._connectionState = ConnectionState.Established;
this._lastReceivedPacketId = remotePacketId;
this._sendAck(remotePacketId);
return;
}
const ps = [];
if (this._connectionState === ConnectionState.Established) {
// Device asked for retransmit
if (flags & PacketFlag.RetransmitRequest) {
const fromPacketId = packet.readUInt16BE(6);
this.log(`Retransmit request: ${fromPacketId}`);
ps.push(this._retransmitFrom(fromPacketId));
}
// Got a packet that needs an ack
if (flags & PacketFlag.AckRequest) {
// Check if it next in the sequence
if (remotePacketId === (this._lastReceivedPacketId + 1) % MAX_PACKET_ID) {
this._lastReceivedPacketId = remotePacketId;
this._sendOrQueueAck();
// It might have commands
if (length > 12) {
ps.push(this.onCommandsReceived(packet.slice(12), remotePacketId));
}
}
else if (this._isPacketCoveredByAck(this._lastReceivedPacketId, remotePacketId)) {
// We got a retransmit of something we have already acked, so reack it
this._sendOrQueueAck();
}
}
// Device ack'ed our packet
if (flags & PacketFlag.AckReply) {
const ackPacketId = packet.readUInt16BE(4);
const ackedCommands = [];
this._inFlight = this._inFlight.filter((pkt) => {
if (this._isPacketCoveredByAck(ackPacketId, pkt.packetId)) {
ackedCommands.push({
packetId: pkt.packetId,
trackingId: pkt.trackingId,
});
return false;
}
else {
// Not acked yet
return true;
}
});
this._triggerRetransmitTimer();
ps.push(this.onPacketsAcknowledged(ackedCommands));
// this.log(`${Date.now()} Got ack ${ackPacketId} Remaining=${this._inFlight.length}`)
}
}
Promise.all(ps).catch((e) => {
this.log(`Failed to receivePacket: ${e?.message ?? e}`);
});
}
_sendPacket(packet) {
if (this._debugBuffers)
this.log(`SEND ${packet.toString('hex')}`);
this._socket.send(packet, 0, packet.length, this._port, this._address);
}
_sendOrQueueAck() {
this._receivedWithoutAck++;
if (this._receivedWithoutAck >= MAX_PACKET_PER_ACK) {
this._receivedWithoutAck = 0;
if (this._ackTimer) {
clearTimeout(this._ackTimer);
delete this._ackTimer;
}
this._sendAck(this._lastReceivedPacketId);
}
else if (!this._ackTimer) {
this._ackTimer = setTimeout(() => {
delete this._ackTimer;
this._receivedWithoutAck = 0;
this._sendAck(this._lastReceivedPacketId);
}, 5);
}
}
_sendAck(packetId) {
const opcode = PacketFlag.AckReply << 11;
const length = 12;
const buffer = Buffer.alloc(length, 0);
buffer.writeUInt16BE(opcode | length, 0);
buffer.writeUInt16BE(this._sessionId, 2);
buffer.writeUInt16BE(packetId, 4);
this._sendPacket(buffer);
}
async _retransmitFrom(fromId) {
// this.log(`Resending from ${fromId} to ${this._inFlight.length > 0 ? this._inFlight[this._inFlight.length - 1].packetId : '-'}`)
// The atem will ask for MAX_PACKET_ID to be retransmitted when it really wants 0
fromId = fromId % MAX_PACKET_ID;
const fromIndex = this._inFlight.findIndex((pkt) => pkt.packetId === fromId);
if (fromIndex === -1) {
// fromId is not inflight, so we cannot resend. only fix is to abort
this.log(`Unable to resend: ${fromId}`);
await this.restartConnection();
}
else {
this.log(`Resending from ${fromId} to ${this._inFlight[this._inFlight.length - 1].packetId}`);
// Resend from the requested
const now = perf_hooks_1.performance.now();
for (let i = fromIndex; i < this._inFlight.length; i++) {
const sentPacket = this._inFlight[i];
if (sentPacket.packetId === fromId || !this._isPacketCoveredByAck(fromId, sentPacket.packetId)) {
sentPacket.lastSent = now;
sentPacket.resent++;
// this.log(`${Date.now()} Resending ${sentPacket.packetId} Last=${this._nextSendPacketId - 1}`)
this._sendPacket(sentPacket.payload);
}
}
}
}
_triggerRetransmitTimer() {
if (!this._inFlight.length) {
if (this._retransmitTimer) {
clearTimeout(this._retransmitTimer);
delete this._retransmitTimer;
}
return;
}
if (!this._retransmitTimer) {
this._retransmitTimer = setTimeout(() => {
delete this._retransmitTimer;
this._checkForRetransmit().catch((e) => {
this.log(`Failed to retransmit: ${e?.message ?? e}`);
});
}, RETRANSMIT_INTERVAL);
}
}
async _checkForRetransmit() {
if (!this._inFlight.length)
return;
this._triggerRetransmitTimer();
const now = perf_hooks_1.performance.now();
for (const sentPacket of this._inFlight) {
if (sentPacket.lastSent + IN_FLIGHT_TIMEOUT < now) {
if (sentPacket.resent <= MAX_PACKET_RETRIES &&
this._isPacketCoveredByAck(this._nextSendPacketId, sentPacket.packetId)) {
this.log(`Retransmit from timeout: ${sentPacket.packetId}`);
// Retransmit the packet and anything after it
return this._retransmitFrom(sentPacket.packetId);
}
else {
// A packet has timed out, so we need to reset to avoid getting stuck
this.log(`Packet timed out: ${sentPacket.packetId}`);
return this.restartConnection();
}
}
}
return Promise.resolve();
}
}
exports.AtemSocketChild = AtemSocketChild;
//# sourceMappingURL=atemSocketChild.js.map