unifi-protect
Version:
A complete implementation of the UniFi Protect API.
122 lines • 6.67 kB
JavaScript
/* Copyright(C) 2019-2026, HJD (https://github.com/hjdhjd). All rights reserved.
*
* protect-api-events.ts: Our UniFi Protect realtime events API packet utilities.
*/
import { promisify } from "node:util";
import zlib from "node:zlib";
// Async zlib inflate for non-blocking decompression on the libuv threadpool.
const inflateAsync = promisify(zlib.inflate);
// UniFi Protect events API packet header size, in bytes.
const EVENT_PACKET_HEADER_SIZE = 8;
// Update realtime API packet types.
var ProtectEventPacketType;
(function (ProtectEventPacketType) {
ProtectEventPacketType[ProtectEventPacketType["ACTION"] = 1] = "ACTION";
ProtectEventPacketType[ProtectEventPacketType["DATA"] = 2] = "DATA";
})(ProtectEventPacketType || (ProtectEventPacketType = {}));
// Update realtime API payload types.
var EventPayloadType;
(function (EventPayloadType) {
EventPayloadType[EventPayloadType["JSON"] = 1] = "JSON";
EventPayloadType[EventPayloadType["STRING"] = 2] = "STRING";
EventPayloadType[EventPayloadType["BUFFER"] = 3] = "BUFFER";
})(EventPayloadType || (EventPayloadType = {}));
/**
* @internal
*
* A packet header is composed of 8 bytes in this order:
*
* | Byte Offset | Description | Bits | Values |
* |-------------|----------------|------|-----------------------------------------------------------------------|
* | 0 | Packet Type | 8 | 1 - action frame, 2 - data frame. |
* | 1 | Payload Format | 8 | 1 - JSON object, 2 - UTF8-encoded string, 3 - Node Buffer. |
* | 2 | Deflated | 8 | 0 - uncompressed, 1 - compressed / deflated (zlib-based compression). |
* | 3 | Unknown | 8 | Always 0. Possibly reserved for future use by Ubiquiti? |
* | 4-7 | Payload Size: | 32 | Size of payload in network-byte order (big endian). |
*/
var ProtectEventPacketHeader;
(function (ProtectEventPacketHeader) {
ProtectEventPacketHeader[ProtectEventPacketHeader["TYPE"] = 0] = "TYPE";
ProtectEventPacketHeader[ProtectEventPacketHeader["PAYLOAD_FORMAT"] = 1] = "PAYLOAD_FORMAT";
ProtectEventPacketHeader[ProtectEventPacketHeader["DEFLATED"] = 2] = "DEFLATED";
ProtectEventPacketHeader[ProtectEventPacketHeader["UNKNOWN"] = 3] = "UNKNOWN";
ProtectEventPacketHeader[ProtectEventPacketHeader["PAYLOAD_SIZE"] = 4] = "PAYLOAD_SIZE";
})(ProtectEventPacketHeader || (ProtectEventPacketHeader = {}));
/**
* Decode a UniFi Protect event packet.
*
* @param log - Logging functions to use.
* @param packet - Input packet to decode.
*
* @returns Promise resolving to a decoded event packet, or `null` if decoding fails.
*
* @remarks A UniFi Protect event packet is an encoded representation of state updates that occur in a UniFi Protect controller. This utility function takes an
* encoded packet as an input, and decodes it into an event header and payload that can be acted upon. An example of its use is in {@link ProtectApi} where, once
* successfully logged into the Protect controller, events are generated automatically and can be accessed by listening to `message` events emitted by
* {@link ProtectApi}.
*/
export async function decodePacket(log, packet) {
// What we need to do here is to split this packet into the header and payload, and decode them.
let dataOffset;
try {
// The payload size begins at byte offset 4 as a big-endian 32-bit integer. When you add the payload size to our header frame size, you get the location of the
// data header frame.
dataOffset = packet.readUInt32BE(ProtectEventPacketHeader.PAYLOAD_SIZE) + EVENT_PACKET_HEADER_SIZE;
// Validate our packet size, just in case we have more or less data than we expect. If we do, we're done for now.
if (packet.length !== (dataOffset + EVENT_PACKET_HEADER_SIZE + packet.readUInt32BE(dataOffset + ProtectEventPacketHeader.PAYLOAD_SIZE))) {
throw new Error("Packet length doesn't match header information.");
}
}
catch (error) {
log.error("Realtime events API: error decoding update packet: %s.", error);
return null;
}
// Decode the action and payload frames in parallel. Both frames are independent and may require zlib decompression, so inflating them concurrently maximizes
// throughput on the libuv threadpool.
try {
const [actionFrame, dataFrame] = await Promise.all([
decodeFrame(log, packet.subarray(0, dataOffset), ProtectEventPacketType.ACTION),
decodeFrame(log, packet.subarray(dataOffset), ProtectEventPacketType.DATA)
]);
if (!actionFrame || !dataFrame) {
return null;
}
return ({ header: actionFrame, payload: dataFrame });
}
catch (error) {
log.error("Realtime events API: error decoding update packet: %s.", error);
return null;
}
}
// Decode a frame, composed of a header and payload, received through the update events API.
async function decodeFrame(log, packet, packetType) {
// Read the packet frame type.
const frameType = packet.readUInt8(ProtectEventPacketHeader.TYPE);
// This isn't the frame type we were expecting - we're done.
if (packetType !== frameType) {
return null;
}
// Read the payload format.
const payloadFormat = packet.readUInt8(ProtectEventPacketHeader.PAYLOAD_FORMAT);
// Decompress the payload if the deflated flag is set, using async inflate to avoid blocking the event loop. For uncompressed payloads, we just slice past the header.
const payload = packet.readUInt8(ProtectEventPacketHeader.DEFLATED) ?
await inflateAsync(packet.subarray(EVENT_PACKET_HEADER_SIZE)) : packet.subarray(EVENT_PACKET_HEADER_SIZE);
// If it's an action frame, it can only have one format.
if (frameType === ProtectEventPacketType.ACTION) {
return (payloadFormat === EventPayloadType.JSON) ? JSON.parse(payload.toString()) : null;
}
// Process the payload format accordingly.
switch (payloadFormat) {
case EventPayloadType.JSON:
// If it's data payload, it can be anything.
return JSON.parse(payload.toString());
case EventPayloadType.STRING:
return payload.toString("utf8");
case EventPayloadType.BUFFER:
return payload;
default:
log.error("Unknown payload packet type received in the realtime events API: %s.", payloadFormat);
return null;
}
}
//# sourceMappingURL=protect-api-events.js.map