@ljames8/hormann-hcp-client
Version:
Hormann Communication Protocol v1 garage door serial client
381 lines (380 loc) • 14.2 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.BatchHCPPacketParser = exports.SimpleHCPPacketParser = exports.PacketFilter = exports.HCPPacket = void 0;
const stream_1 = require("stream");
const debug_1 = __importDefault(require("debug"));
const utils_1 = require("./utils");
function formatByte(byte) {
return "0x" + byte.toString(16).padStart(2, "0");
}
debug_1.default.formatters.h = utils_1.hex;
debug_1.default.formatters.x = formatByte;
const debug = (0, debug_1.default)("hcp:parser");
const PACKET_OVERHEAD = 3;
const MAX_PACKET_LENGTH = 15 + PACKET_OVERHEAD;
var PKT_HEADER;
(function (PKT_HEADER) {
PKT_HEADER[PKT_HEADER["ADDRESS"] = 0] = "ADDRESS";
PKT_HEADER[PKT_HEADER["LENGTH"] = 1] = "LENGTH";
PKT_HEADER[PKT_HEADER["__SIZE"] = 2] = "__SIZE";
})(PKT_HEADER || (PKT_HEADER = {}));
/** The Hormann Communication Protocol (HCP) uses a type of LIN messaging protocol
* the master is the garage door driver, the slave is the external controller (eg UAP1 device)
* the RS485 transceiver may translate the LIN SYNC BREAKS to 0x00 bytes
*/
const SYNC_BREAK = 0x00;
class HCPPacket extends Uint8Array {
// do not override constructor as it's overloaded and used by some methods (subarray)
// use a factory instead
static fromBuffer(buffer, validate = true) {
// sanity checks
if (buffer.length < PACKET_OVERHEAD + 1) {
throw new Error(`HCPPacket cannot be shorter than ${PACKET_OVERHEAD + 1} bytes`);
}
else if (buffer.length > MAX_PACKET_LENGTH) {
throw new Error(`HCPPacket cannot be longer than ${MAX_PACKET_LENGTH} bytes`);
}
const packet = new HCPPacket(buffer);
if (validate === true)
packet._validate();
return packet;
}
get address() {
return this[PKT_HEADER.ADDRESS];
}
get lengthNibble() {
return this[PKT_HEADER.LENGTH] & 0x0f;
}
get counterNibble() {
// for a uint8 (x & 0xf0) >> 4 equiv to x >> 4
return this[PKT_HEADER.LENGTH] >> 4;
}
get header() {
return this.subarray(0, PKT_HEADER.__SIZE);
}
get payload() {
return this.subarray(PKT_HEADER.__SIZE, -1);
}
get crc() {
return this[this.length - 1];
}
_validate() {
/**
* Ensure consistency of HCP packet length and CRC
*/
// check crc value
const expectedCRC = this.computeCRC();
if (expectedCRC != this.crc) {
throw new Error(`Invalid CRC (got ${formatByte(this.crc)} expected ${formatByte(expectedCRC)})`);
}
// check length nibble is good
const parseLength = this.lengthNibble + PACKET_OVERHEAD;
if (parseLength != this.length) {
throw new Error(`Invalid total length (got ${parseLength} expected ${this.length})`);
}
return true;
}
equals(other) {
return (0, utils_1.arraysEqual)(this, other);
}
computeCRC() {
return (0, utils_1.computeCRC8)(this.subarray(0, this.length - 1));
}
isValid() {
try {
return this._validate();
}
catch {
return false;
}
}
hex() {
return (0, utils_1.hex)(this);
}
static fromData(addressByte, counterNibble, payload, crc, validate = crc === undefined ? false : true) {
let tmpCRC = -1;
// create packet buffer from its data
if (addressByte > 0xff)
throw new Error("address byte cannot exceed 255");
if (counterNibble > 0xf)
throw new Error("counter nibble cannot exceed 15");
if (crc !== undefined) {
if (crc > 0xff)
throw new Error("crc cannot exceed 255");
tmpCRC = crc;
}
const lengthByte = (counterNibble << 4) + payload.length;
const buffer = [addressByte, lengthByte, ...payload, tmpCRC];
// compute crc if undefined
if (crc === undefined) {
buffer[buffer.length - 1] = (0, utils_1.computeCRC8)(buffer.slice(0, buffer.length - 1));
}
return HCPPacket.fromBuffer(buffer, validate);
}
}
exports.HCPPacket = HCPPacket;
class PacketFilter extends stream_1.Transform {
/**
* Filtering for valid chunks based on timing
* and other HCP characteristics
*/
buffer;
timeout;
timer;
filterMaxLength;
filterBreaks;
constructor({ packetTimeout = -1, filterMaxLength = false, filterBreaks = false, } = {}) {
super({ objectMode: true });
this.buffer = this._initBuffer();
this.timer = null;
this.timeout = packetTimeout;
this.filterMaxLength = filterMaxLength;
this.filterBreaks = filterBreaks;
}
_initBuffer() {
// to override
return Buffer.alloc(0);
}
_resetBuffer = () => {
// to override
this.buffer = this._initBuffer();
};
_clearTimeout() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
_filter(chunk) {
// method to preprocess chunk before passing it to _transform
if (this.timeout > 0) {
this._clearTimeout();
this.timer = setTimeout(this._resetBuffer, this.timeout);
}
if (this.filterMaxLength === true && chunk.length > MAX_PACKET_LENGTH) {
// keep the last MAX_PACKET_LENGTH bytes from the big chunk
return chunk.subarray(chunk.length - MAX_PACKET_LENGTH);
}
else if (this.filterBreaks === true && chunk[0] == SYNC_BREAK && chunk.length > 1) {
// discard first 0x00 byte
return chunk.subarray(1);
}
else {
return chunk;
}
}
_transform(chunk, encoding, callback) {
// default example transform is an accumulator, to override
this.buffer = Buffer.concat([this.buffer, this._filter(chunk)]);
this.push(this.buffer);
callback();
}
}
exports.PacketFilter = PacketFilter;
class SimpleHCPPacketParser extends PacketFilter {
/**
* Iterates over read bytes one by one.
* Drops all bytes if packet is invalid
* So it can miss valid packets in case of glitches
*/
started;
offset;
packetLength;
constructor(options = {}) {
super(options);
this.started = false;
this._resetPacket();
}
_initBuffer() {
return Buffer.alloc(MAX_PACKET_LENGTH);
}
_resetBuffer = () => {
return this._resetPacket();
};
_resetPacket() {
// offset when several chunks are needed to get a full packet
this.offset = -1;
// total packetLength computed from LENGTH byte
this.packetLength = 0;
this.buffer.fill(0);
}
_parseCurrentByte(byte) {
switch (this.offset) {
case PKT_HEADER.ADDRESS:
// TODO: limit the possibilites for 1st byte ?
debug("address %x", byte);
break;
case PKT_HEADER.LENGTH:
// parse packet length and message counter values
this.packetLength = (byte & 0x0f) + PACKET_OVERHEAD;
debug("parsed packet length %d ((%x & 0x0f) + %d)", this.packetLength, byte, PACKET_OVERHEAD);
break;
// const message_counter = (byte & 0xf0) >> 4;
case this.packetLength - 1: {
// packet complete
const packetCRC = (0, utils_1.computeCRC8)(this.buffer.subarray(0, this.offset));
debug("packet complete: %h CRC %x", this.buffer.subarray(0, this.offset + 1), packetCRC);
if (packetCRC == byte) {
// packet valid
this.push(HCPPacket.fromBuffer(this.buffer.subarray(0, this.offset + 1), false));
}
else {
debug("CRC error, expected %x", byte);
}
return false;
}
case MAX_PACKET_LENGTH:
// TODO: to delete, cannot happen?
// packet too long, restart packet
debug("packet too long, restart packet");
return false;
default:
debug("processed byte %x", byte);
}
return true;
}
_transform(chunk, encoding, cb) {
/**
* Parses valid HCP packets from a stream of bytes
* Pushes HCPPacket instances
*/
debug("reading chunk: %h", chunk);
const filteredChunk = this._filter(chunk);
for (const byte of filteredChunk) {
if (this.started === false) {
// TODO: additional condition to restart packet?
this._resetPacket();
this.started = true;
}
if (this.started === true) {
this.offset++;
this.buffer[this.offset] = byte;
debug("offset", this.offset);
this.started = this._parseCurrentByte(byte);
// TODO: migh as well break than restart a packet on next byte if false ?
}
}
cb();
}
}
exports.SimpleHCPPacketParser = SimpleHCPPacketParser;
class BatchHCPPacketParser extends PacketFilter {
/**
* Iterates over each chunk of read bytes to find valid packet as early as possible
* Might create false positive packets in case of CRC collision
*/
offset;
tested;
minUntestedIdx;
constructor(options = {}) {
super(options);
this.tested = new Array(MAX_PACKET_LENGTH).fill(false);
// queue buffer with enough space to hold full packet data for tested array
this.offset = 0;
this.minUntestedIdx = 0;
}
_initBuffer() {
return Buffer.alloc(2 * MAX_PACKET_LENGTH - 1).fill(0);
}
_resetBuffer = () => {
this.buffer.fill(0);
this.offset = 0;
return this._resetTestedArray();
};
_resetTestedArray() {
this.tested.fill(false);
this.minUntestedIdx = 0;
}
_pop_buffer(nbElements) {
this.buffer.copyWithin(0, nbElements);
this.offset -= nbElements;
}
_testPacket(offset, length) {
if (length < PACKET_OVERHEAD + 1) {
// packet cannot be empty
return false;
}
const testBuffer = this.buffer.subarray(offset, offset + length - 1);
const packetCRC = (0, utils_1.computeCRC8)(testBuffer);
debug("computed CRC %x expected CRC %x for buffer %h", packetCRC, this.buffer[offset + length - 1], testBuffer);
return packetCRC == this.buffer[offset + length - 1];
}
_testPacketRange(fromByteIdx, untilByteIdx) {
const maxTestIdx = Math.min(untilByteIdx - PACKET_OVERHEAD - 1, this.tested.length - 1);
let parsedLength;
for (let i = fromByteIdx; i <= maxTestIdx; i++) {
if (this.tested[i] === true) {
// already tested, skip
continue;
}
// check candidate packet starting at byte i
parsedLength = (this.buffer[i + PKT_HEADER.LENGTH] & 0x0f) + PACKET_OVERHEAD;
debug("packet parsed length %d from byte %d", parsedLength, i);
if (i + parsedLength > untilByteIdx) {
continue;
}
// if enough data available test it
if (this._testPacket(i, parsedLength) === true) {
// push packet and rewind
debug("pushing valid packet %h", this.buffer.subarray(i, i + parsedLength));
this.push(HCPPacket.fromBuffer(this.buffer.subarray(i, i + parsedLength), false));
this._resetTestedArray();
return i + parsedLength;
}
else {
this.tested[i] = true;
if (i == this.tested.length - 1) {
// if reached the end of tested array, clear it and rewind
debug("test array complete, rewinding");
this._resetTestedArray();
return this.tested.length;
}
else if (i == this.minUntestedIdx) {
// increment the marker
this.minUntestedIdx++;
}
}
}
return 0;
}
_transform(bytes, _encoding, cb) {
/**
* Parses valid HCP packets from a stream of bytes
* Pushes HCPPacket instances
*/
debug("reading chunk: %h", bytes);
const filtered = this._filter(bytes);
let bytesOffset = 0;
let remainingBytes = filtered.length;
let chunkSize;
let bytesToPop;
let bkpOffset = -1;
// while we consume bytes
while (bkpOffset != this.offset) {
// fill queue
debug("minUntestedIdx", this.minUntestedIdx);
chunkSize = Math.min(this.buffer.length - this.offset, remainingBytes);
if (chunkSize > 0) {
this.buffer.fill(filtered.subarray(bytesOffset, bytesOffset + chunkSize), this.offset, this.offset + chunkSize);
this.offset += chunkSize;
bytesOffset += chunkSize;
remainingBytes = filtered.length - bytesOffset;
}
debug("buffer", this.buffer);
debug("offset %d remaining %d bytes to read", this.offset, remainingBytes);
// test candidate packets from minUntestedIdx
bytesToPop = this._testPacketRange(this.minUntestedIdx, this.offset);
// save backup offset and pop buffers to rewind
bkpOffset = this.offset;
debug("%d bytes to pop after test, bkpOffset %d", bytesToPop, bkpOffset);
if (bytesToPop > 0) {
this._pop_buffer(bytesToPop);
}
}
cb();
}
}
exports.BatchHCPPacketParser = BatchHCPPacketParser;