UNPKG

homebridge-plugin-utils

Version:

Opinionated utilities to provide common capabilities and create rich configuration webUI experiences for Homebridge plugins.

178 lines 8.81 kB
/* Copyright(C) 2017-2025, HJD (https://github.com/hjdhjd). All rights reserved. * * rtp.ts: RTP-related utilities to slice and dice RTP streams. * * This module is heavily inspired by the homebridge and homebridge-camera-ffmpeg source code and borrows from both. Thank you for your contributions to the community. */ import { EventEmitter, once } from "node:events"; import { createSocket } from "node:dgram"; // How often, in seconds, should we heartbeat FFmpeg in two-way audio sessions. This should be less than 5 seconds, which is FFmpeg's input timeout interval. const TWOWAY_HEARTBEAT_INTERVAL = 3; /** * Here's the problem this class solves: FFmpeg doesn't support multiplexing RTP and RTCP data on a single UDP port (RFC 5761). If it did, we wouldn't need this * workaround for HomeKit compatibility, which does multiplex RTP and RTCP over a single UDP port. * * This class inspects all packets coming in from inputPort and demultiplexes RTP and RTCP traffic to rtpPort and rtcpPort, respectively. * * Credit to @dgreif and @brandawg93 who graciously shared their code as a starting point, and their collaboration in answering the questions needed to bring all this * together. A special thank you to @Sunoo for the many hours of discussion and brainstorming on this and other topics. */ export class RtpDemuxer extends EventEmitter { heartbeatTimer; heartbeatMsg; _isRunning; log; inputPort; socket; // Create an instance of RtpDemuxer. constructor(ipFamily, inputPort, rtcpPort, rtpPort, log) { super(); this._isRunning = false; this.log = log; this.inputPort = inputPort; this.socket = createSocket(ipFamily === "ipv6" ? "udp6" : "udp4"); // Catch errors when they happen on our demuxer. this.socket.on("error", (error) => { this.log?.error("RtpDemuxer Error: %s", error); this.socket.close(); }); // Split the message into RTP and RTCP packets. this.socket.on("message", (msg) => { // Send RTP packets to the RTP port. if (this.isRtpMessage(msg)) { this.emit("rtp"); this.socket.send(msg, rtpPort); } else { // Save this RTCP message for heartbeat purposes for the RTP port. This works because RTCP packets will be ignored // by ffmpeg on the RTP port, effectively providing a heartbeat to ensure FFmpeg doesn't timeout if there's an // extended delay between data transmission. this.heartbeatMsg = Buffer.from(msg); // Clear the old heartbeat timer. clearTimeout(this.heartbeatTimer); this.heartbeat(rtpPort); // RTCP control packets should go to the RTCP port. this.socket.send(msg, rtcpPort); } }); this.log?.debug("Creating an RtpDemuxer instance - inbound port: %s, RTCP port: %s, RTP port: %s.", this.inputPort, rtcpPort, rtpPort); // Take the socket live. this.socket.bind(this.inputPort); this._isRunning = true; } // Send a regular heartbeat to FFmpeg to ensure the pipe remains open and the process alive. heartbeat(port) { // Clear the old heartbeat timer. clearTimeout(this.heartbeatTimer); // Send a heartbeat to FFmpeg every few seconds to keep things open. FFmpeg has a five-second timeout // in reading input, and we want to be comfortably within the margin for error to ensure the process // continues to run. this.heartbeatTimer = setTimeout(() => { this.log?.debug("Sending ffmpeg a heartbeat."); this.socket.send(this.heartbeatMsg, port); this.heartbeat(port); }, TWOWAY_HEARTBEAT_INTERVAL * 1000); } // Close the socket and cleanup. close() { this.log?.debug("Closing the RtpDemuxer instance on port %s.", this.inputPort); clearTimeout(this.heartbeatTimer); this.socket.close(); this._isRunning = false; this.emit("rtp"); } // Retrieve the payload information from a packet to discern what the packet payload is. getPayloadType(message) { return message.readUInt8(1) & 0x7f; } // Return whether or not a packet is RTP (or not). isRtpMessage(message) { const payloadType = this.getPayloadType(message); return (payloadType > 90) || (payloadType === 0); } // Inform people whether we are up and running or not. get isRunning() { return this._isRunning; } } /* RTP port allocator class that keeps track of UDP ports that are currently earmarked for use. We need this when allocating ports that we use for various network * activities such as demuxing FFmpeg or opening up other sockets. Otherwise, we run a risk (especially in environment where there are many such requests) of allocating * the same port multiple times and end up erroring out unceremoniously. */ export class RtpPortAllocator { portsInUse; // Instantiate our port retrieval. constructor() { // Initialize our in use tracker. this.portsInUse = {}; } // Find an available UDP port by binding to one to validate it's availability. async getPort(ipFamily, port = 0) { try { // Keep looping until we find what we're looking for: local UDP ports that are unspoken for. for (;;) { // Create a datagram socket, so we can use it to find a port. const socket = createSocket(ipFamily === "ipv6" ? "udp6" : "udp4"); // Exclude this socket from Node's reference counting so we don't have issues later. socket.unref(); // Listen for the bind event. const eventListener = once(socket, "listening"); // Bind to the port in question. If port is set to 0, we'll get a randomly generated port generated for us. socket.bind(port); // Ensure we wait for the socket to be bound. // eslint-disable-next-line no-await-in-loop await eventListener; // Retrieve the port number we've gotten from the bind request. const assignedPort = socket.address().port; // We're done with the socket, let's cleanup. socket.close(); // Check to see if the port is one we're already using. If it is, try again. if (this.portsInUse[assignedPort]) { continue; } // Now let's mark the port in use. this.portsInUse[assignedPort] = true; // Return the port. return assignedPort; } } catch (error) { return -1; } } // Reserve consecutive ports for use with FFmpeg. FFmpeg currently lacks the ability to specify both the RTP and RTCP ports. FFmpeg always assumes, by convention, that // when you specify an RTP port, the RTCP port is the RTP port + 1. In order to work around that challenge, we need to always ensure that when we reserve multiple ports // for RTP (primarily for two-way audio) that we we are reserving consecutive ports only. async reserve(ipFamily = "ipv4", portCount = 1, attempts = 0) { // Sanity check and make sure we're not requesting any more than two ports at a time, or if we've exceeded our attempt limit. if (![1, 2].includes(portCount) || (attempts > 10)) { return -1; } let firstPort = 0; // Find the appropriate number of ports being requested. for (let i = 0; i < portCount; i++) { // eslint-disable-next-line no-await-in-loop const assignedPort = await this.getPort(ipFamily, firstPort ? firstPort + 1 : 0); // We haven't gotten a port, let's try again. if (assignedPort === -1) { // If we've gotten the first port of a pair of ports, make sure we release it here. if (firstPort) { this.cancel(firstPort); } // We still haven't found what we're looking for...keep looking. return this.reserve(ipFamily, portCount, attempts++); } // We've seen the first port we may be looking for, let's save it. if (!firstPort) { firstPort = assignedPort; } } // Return the first port we've found. return firstPort; } // Delete a port reservation that's no longer needed. cancel(port) { delete this.portsInUse[port]; } } //# sourceMappingURL=rtp.js.map