UNPKG

homebridge-plugin-utils

Version:

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

335 lines 14.1 kB
/* Copyright(C) 2017-2025, HJD (https://github.com/hjdhjd). All rights reserved. * * ffmpeg/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. */ /** * RTP and RTCP packet demultiplexer and UDP port management for FFmpeg-based HomeKit livestreaming. * * This module supplies classes and helpers to support realtime streaming via FFmpeg in Homebridge and similar HomeKit environments. It enables the demultiplexing of RTP * and RTCP packets on a single UDP port, as required by HomeKit and RFC 5761, working around FFmpeg’s lack of native support for RTP/RTCP multiplexing. It also manages * the allocation and tracking of UDP ports for RTP and RTCP, helping prevent conflicts in dynamic, multi-session streaming scenarios. * * Key features: * * - Demultiplexes RTP and RTCP packets received on a single UDP port, forwarding them to the correct FFmpeg destinations for HomeKit livestream compatibility. * - Injects periodic heartbeat messages to keep two-way audio streams alive with FFmpeg’s strict timeout requirements. * - Dynamically allocates and reserves UDP ports for RTP/RTCP, supporting consecutive port pairing for correct FFmpeg operation. * - Event-driven architecture for integration with plugin or automation logic. * * Designed for plugin developers and advanced users implementing HomeKit livestreaming, audio/video bridging, or similar applications requiring precise RTP/RTCP handling * with FFmpeg. * * @module */ 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; /** * Utility for demultiplexing RTP and RTCP packets on a single UDP port for HomeKit compatibility. * * FFmpeg does not support multiplexing RTP and RTCP data on a single UDP port (RFC 5761) and HomeKit requires this for livestreaming. This class listens on a UDP port * and demultiplexes RTP and RTCP traffic, forwarding them to separate RTP and RTCP ports as required by FFmpeg. * * Credit to [dgreif](https://github.com/dgreif), [brandawg93](https://github.com/brandawg93), and [Sunoo](https://github.com/Sunoo) for foundational ideas and * collaboration. * * @example * * ```ts * // Create an RtpDemuxer to split packets for FFmpeg compatibility. * const demuxer = new RtpDemuxer("ipv4", 50000, 50002, 50004, log); * * // Close the demuxer when finished. * demuxer.close(); * ``` * * @see {@link https://tools.ietf.org/html/rfc5761 | RFC 5761} * @see {@link https://github.com/homebridge/homebridge-camera-ffmpeg | homebridge-camera-ffmpeg} * * @category FFmpeg */ export class RtpDemuxer extends EventEmitter { heartbeatTimer; heartbeatMsg; _isRunning; log; inputPort; socket; /** * Constructs a new RtpDemuxer for a specified IP family and port set. * * @param ipFamily - The IP family: "ipv4" or "ipv6". * @param inputPort - The UDP port to listen on for incoming packets. * @param rtcpPort - The UDP port to forward RTCP packets to. * @param rtpPort - The UDP port to forward RTP packets to. * @param log - Logger instance for debug and error messages. * * @example * * ```ts * const demuxer = new RtpDemuxer("ipv4", 50000, 50002, 50004, log); * ``` */ 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; } /** * Sends periodic heartbeat messages to the RTP port to keep the FFmpeg process alive. * * This is necessary because FFmpeg times out input streams if it does not receive data for more than five seconds. * * @param port - The RTP port to send the heartbeat to. */ 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); } /** * Closes the demuxer, its socket, and any heartbeat timers. * * @example * * ```ts * demuxer.close(); * ``` */ 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"); } /** * Extracts the RTP payload type from a UDP packet. * * Used internally to distinguish RTP from RTCP messages. * * @param message - The UDP packet buffer. * * @returns The RTP payload type as a number. */ getPayloadType(message) { return message.readUInt8(1) & 0x7f; } /** * Determines if the provided UDP packet is an RTP message. * * @param message - The UDP packet buffer. * * @returns `true` if the packet is RTP, `false` if RTCP. */ isRtpMessage(message) { const payloadType = this.getPayloadType(message); return (payloadType > 90) || (payloadType === 0); } /** * Indicates if the demuxer is running and accepting packets. * * @returns `true` if running, otherwise `false`. * * @example * * ```ts * if(demuxer.isRunning) { * // Demuxer is active. * } * ``` */ get isRunning() { return this._isRunning; } } /** * Allocates and tracks UDP ports for RTP and RTCP to avoid port conflicts in environments with high network activity. * * This utility class is used to find and reserve available UDP ports for demuxing FFmpeg streams or other network activities. * * @example * * ```ts * const allocator = new RtpPortAllocator(); * * // Reserve two consecutive ports for RTP and RTCP. * const rtpPort = await allocator.reserve("ipv4", 2); * * // Cancel reservation if not needed. * allocator.cancel(rtpPort); * ``` * * @category FFmpeg */ export class RtpPortAllocator { portsInUse; /** * Instantiates a new RTP port allocator and tracker. */ constructor() { // Initialize our in use tracker. this.portsInUse = {}; } /** * Finds an available UDP port by attempting to bind a new socket. * * Loops until an available port not already marked as in use is found. * * @param ipFamily - "ipv4" or "ipv6". * @param port - Optional. The port to try to bind to. If 0, selects a random port. * * @returns A promise resolving to the available port number, or `-1` on error. */ 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; } } /** * Internal method to reserve one or two consecutive UDP ports for FFmpeg or network use. * * If two ports are reserved, ensures they are consecutive for RTP and RTCP usage. Returns the first port in the sequence, or `-1` if we're unable to allocate. * * @param ipFamily - Optional. "ipv4" or "ipv6". Defaults to "ipv4". * @param portCount - Optional. The number of consecutive ports to reserve (1 or 2). Defaults to 1. * @param attempts - Internal. The number of allocation attempts. Used for recursion. * * @returns A promise resolving to the first reserved port, or `-1` if unavailable. */ 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; } /** * Reserves one or two consecutive UDP ports for FFmpeg or network use. * * If two ports are reserved, ensures they are consecutive for RTP and RTCP usage. Returns the first port in the sequence, or `-1` if we're unable to allocate. * * @param ipFamily - Optional. "ipv4" or "ipv6". Defaults to "ipv4". * @param portCount - Optional. The number of consecutive ports to reserve (1 or 2). Defaults to 1. * * @returns A promise resolving to the first reserved port, or `-1` if unavailable. * * @remarks 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 * use cases) that we we are reserving consecutive ports only. * * @example * * ```ts * // Reserve a single port. * const port = await allocator.reserve(); * * // Reserve two consecutive ports for RTP/RTCP. * const rtpPort = await allocator.reserve("ipv4", 2); * ``` */ async reserve(ipFamily = "ipv4", portCount = 1) { return this._reserve(ipFamily, portCount); } /** * Cancels and releases a previously reserved port, making it available for future use. * * @param port - The port number to release. * * @example * * ```ts * allocator.cancel(50000); * ``` */ cancel(port) { delete this.portsInUse[port]; } } //# sourceMappingURL=rtp.js.map