UNPKG

@ljames8/hormann-hcp-client

Version:

Hormann Communication Protocol v1 garage door serial client

381 lines (380 loc) 14.2 kB
"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;