UNPKG

@ljames8/hormann-hcp-client

Version:

Hormann Communication Protocol v1 garage door serial client

292 lines (291 loc) 12.6 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SerialHCPClient = exports.BROADCAST_STATUS_BYTE0_BITFIELD = exports.DIRECTION = exports.STATUS_RESPONSE_BYTE0_BITFIELD = void 0; const events_1 = require("events"); const serialport_1 = require("serialport"); const debug_1 = __importDefault(require("debug")); const utils_1 = require("./utils"); const parser_1 = require("./parser"); const debug = (0, debug_1.default)("hcp:client"); const trace = (0, debug_1.default)("hcp:serial"); const DEFAULT_BAUDRATE = 19200; const MIN_RESPONSE_DELAY_MS = 3; // LIN message sync break must be at least 13 bits long // so 13/19200 ~= 0.68ms const SYNC_BREAK_DURATION_MS = (13 * 1000) / DEFAULT_BAUDRATE; const UAP1_ADDR = 0x28; // other addresses will do as well // using a type from 0x00 to 0x03 seems to make communication errors to be discarded by the master const UAP1_TYPE = 0x02; var ADDRESS; (function (ADDRESS) { ADDRESS[ADDRESS["BROADCAST"] = 0] = "BROADCAST"; ADDRESS[ADDRESS["MASTER"] = 128] = "MASTER"; ADDRESS[ADDRESS["SLAVE"] = 40] = "SLAVE"; })(ADDRESS || (ADDRESS = {})); var COMMAND; (function (COMMAND) { COMMAND[COMMAND["SLAVE_SCAN"] = 1] = "SLAVE_SCAN"; COMMAND[COMMAND["SLAVE_STATUS_REQUEST"] = 32] = "SLAVE_STATUS_REQUEST"; COMMAND[COMMAND["SLAVE_STATUS_RESPONSE"] = 41] = "SLAVE_STATUS_RESPONSE"; })(COMMAND || (COMMAND = {})); var STATUS_RESPONSE_BYTE0_BITFIELD; (function (STATUS_RESPONSE_BYTE0_BITFIELD) { STATUS_RESPONSE_BYTE0_BITFIELD[STATUS_RESPONSE_BYTE0_BITFIELD["OPEN"] = 0] = "OPEN"; STATUS_RESPONSE_BYTE0_BITFIELD[STATUS_RESPONSE_BYTE0_BITFIELD["CLOSE"] = 1] = "CLOSE"; STATUS_RESPONSE_BYTE0_BITFIELD[STATUS_RESPONSE_BYTE0_BITFIELD["REVERSE"] = 2] = "REVERSE"; STATUS_RESPONSE_BYTE0_BITFIELD[STATUS_RESPONSE_BYTE0_BITFIELD["TOGGLE_LIGHT"] = 3] = "TOGGLE_LIGHT"; STATUS_RESPONSE_BYTE0_BITFIELD[STATUS_RESPONSE_BYTE0_BITFIELD["VENTING"] = 4] = "VENTING"; })(STATUS_RESPONSE_BYTE0_BITFIELD || (exports.STATUS_RESPONSE_BYTE0_BITFIELD = STATUS_RESPONSE_BYTE0_BITFIELD = {})); var STATUS_RESPONSE_BYTE1_VALUE; (function (STATUS_RESPONSE_BYTE1_VALUE) { /* different values for byte #1 of slave status response */ // will emergency stop if this byte is not 0x10 STATUS_RESPONSE_BYTE1_VALUE[STATUS_RESPONSE_BYTE1_VALUE["DEFAULT"] = 16] = "DEFAULT"; STATUS_RESPONSE_BYTE1_VALUE[STATUS_RESPONSE_BYTE1_VALUE["STOP"] = 0] = "STOP"; })(STATUS_RESPONSE_BYTE1_VALUE || (STATUS_RESPONSE_BYTE1_VALUE = {})); var DIRECTION; (function (DIRECTION) { DIRECTION[DIRECTION["OPENING"] = 0] = "OPENING"; DIRECTION[DIRECTION["CLOSING"] = 1] = "CLOSING"; })(DIRECTION || (exports.DIRECTION = DIRECTION = {})); var BROADCAST_STATUS_BYTE0_BITFIELD; (function (BROADCAST_STATUS_BYTE0_BITFIELD) { BROADCAST_STATUS_BYTE0_BITFIELD[BROADCAST_STATUS_BYTE0_BITFIELD["DOOR_CLOSED"] = 0] = "DOOR_CLOSED"; BROADCAST_STATUS_BYTE0_BITFIELD[BROADCAST_STATUS_BYTE0_BITFIELD["DOOR_OPENED"] = 1] = "DOOR_OPENED"; BROADCAST_STATUS_BYTE0_BITFIELD[BROADCAST_STATUS_BYTE0_BITFIELD["EXT_RELAY_ON"] = 2] = "EXT_RELAY_ON"; BROADCAST_STATUS_BYTE0_BITFIELD[BROADCAST_STATUS_BYTE0_BITFIELD["LIGHT_RELAY_ON"] = 3] = "LIGHT_RELAY_ON"; BROADCAST_STATUS_BYTE0_BITFIELD[BROADCAST_STATUS_BYTE0_BITFIELD["ERROR_ACTIVE"] = 4] = "ERROR_ACTIVE"; BROADCAST_STATUS_BYTE0_BITFIELD[BROADCAST_STATUS_BYTE0_BITFIELD["DOOR_DIRECTION"] = 5] = "DOOR_DIRECTION"; BROADCAST_STATUS_BYTE0_BITFIELD[BROADCAST_STATUS_BYTE0_BITFIELD["DOOR_MOVING"] = 6] = "DOOR_MOVING"; BROADCAST_STATUS_BYTE0_BITFIELD[BROADCAST_STATUS_BYTE0_BITFIELD["DOOR_VENTING"] = 7] = "DOOR_VENTING"; })(BROADCAST_STATUS_BYTE0_BITFIELD || (exports.BROADCAST_STATUS_BYTE0_BITFIELD = BROADCAST_STATUS_BYTE0_BITFIELD = {})); class SerialHCPClient extends events_1.EventEmitter { parser; port; nextMessageCounter; sendQueue; constructor({ path, baudRate = DEFAULT_BAUDRATE, ...rest }, parserOptions) { super(); this.port = new serialport_1.SerialPort({ path, baudRate, ...rest }); this.port.on("open", this.onOpen.bind(this)); this.port.on("close", this.onClose.bind(this)); this.port.on("error", this.onError.bind(this)); this.parser = new parser_1.BatchHCPPacketParser(parserOptions); this.parser.on("data", this.onNewPacket.bind(this)); this.nextMessageCounter = 1; this.sendQueue = []; this.port.pipe(this.parser); } onOpen() { trace("Serial port opened"); this.emit("open"); } onClose() { trace("Serial port closed"); this.emit("close"); } onError(error) { console.error("Serial port error", error); this.emit("error", error); } onNewPacket(packet) { // new HCP packet was read and parsed from serial port // debug("got packet %h", packet); const timestamp = performance.now(); let response = null; try { response = this.processMessage(packet); } catch (error) { this.emit("error", error); } if (response !== null) { const packet = parser_1.HCPPacket.fromData(ADDRESS.MASTER, response.counter, response.payload); debug("responding with %h", packet); this.sendPacket(packet, MIN_RESPONSE_DELAY_MS - performance.now() + timestamp) .then(() => { response.resolve(packet); }) .catch((reason) => { response.reject?.("TX error: " + reason); }); } } static extractBitfield(byte) { const bits = []; for (let i = 0; i < 8; i++) { bits[i] = ((1 << i) & byte) != 0; } return bits; } static createSlaveStatusPayload(flags, emergencyStop = false) { let byte0 = 0x00; for (const flag of flags) { byte0 |= 1 << flag; } const byte1 = emergencyStop === true ? STATUS_RESPONSE_BYTE1_VALUE.STOP : STATUS_RESPONSE_BYTE1_VALUE.DEFAULT; return [COMMAND.SLAVE_STATUS_RESPONSE, byte0, byte1]; } static getNextCounter(counter) { return (counter + 1) % 16; } open() { if (!this.port.isOpen) return this.port.open(); } close() { if (this.port.isOpen) return this.port.close(); } sendBreak(delay, callback) { /** Use synchronous code to ensure accurate delay */ const start = performance.now(); this.port.set({ brk: true }, (error) => { if (error) throw error; const stop = performance.now(); if (delay > 0) { const timeout = stop + delay - (stop - start) / 2; while (performance.now() < timeout) { // busy wait } } this.port.set({ brk: false }, (error) => { if (error) throw error; callback(); }); }); } async sendPacket(packet, delay) { if (delay !== undefined && delay > 0) { trace(`sleeping for ${delay}ms before sending`); await new Promise((resolve) => setTimeout(resolve, delay)); } return new Promise((resolve, reject) => { // send a sync break first trace("sending sync break"); this.sendBreak(SYNC_BREAK_DURATION_MS, () => { trace("sending packet"); this.port.write(packet, (error) => { if (error) { return reject(error); } else { // TODO: drain write buffer? return resolve(); } }); }); }); } processMessage(packet) { const nextCounter = SerialHCPClient.getNextCounter(packet.counterNibble); if (packet.counterNibble != this.nextMessageCounter) { if (packet.address == ADDRESS.BROADCAST) { // only warn and force sync next counter debug("warning: syncing broadcast counter, " + `got ${packet.counterNibble} expected ${this.nextMessageCounter}`); } else { // error for incorrect counter for other cases throw new Error(`Invalid message counter, got ${packet.counterNibble} ` + `expected ${this.nextMessageCounter}`); } } this.nextMessageCounter = nextCounter; switch (packet.address) { case ADDRESS.BROADCAST: { this.emit("data", SerialHCPClient.unpackBroadcast(packet)); break; } case ADDRESS.SLAVE: { this.nextMessageCounter = nextCounter; const response = this.processSlaveCommand(packet); // set response counter if (response.counter === undefined) response.counter = this.nextMessageCounter; if (response.reject === undefined) response.reject = (reason) => { throw new Error(reason); }; return response; } default: // ignoring message } return null; } static unpackBroadcast(packet) { /** * Unpack both broadcast status packet bytes */ const payload = packet.payload; if (payload.length != 2) throw new Error(`Payload ${(0, utils_1.hex)(payload)} of length ${payload.length}, expecting 2`); return payload; } processSlaveCommand(packet) { const payload = packet.payload; switch (payload[0]) { case COMMAND.SLAVE_SCAN: { debug("received slave scan query %h", packet); // sanity check if (payload.length != 2 || payload[1] != ADDRESS.MASTER) { throw new Error(`Unexpected payload ${(0, utils_1.hex)(payload)} for slave scan packet`); } // reply return { payload: [UAP1_TYPE, UAP1_ADDR], resolve: (p) => { this.emit("init", p); }, reject: () => { throw new Error("could not respond to scan"); }, }; } case COMMAND.SLAVE_STATUS_REQUEST: { debug("got slave status request %h", packet); // sanity check if (payload.length != 1) { throw new Error(`Unexpected payload length for slave status request (${payload.length})`); } if (this.sendQueue.length > 0) { // pop queue return this.sendQueue.shift(); } else { // queue empty, default response // looks like it still works with the UAP1_TYPE=0x02 trick if not answering // or less frequently (up to 1 out of 6 times to keep low command latency) return { payload: SerialHCPClient.createSlaveStatusPayload([]), resolve: () => { }, reject: () => { throw new Error("could not respond to slave status request"); }, }; } } default: throw new Error(`Unknown slave command code ${packet.payload[0]} in packet ${packet.hex()}`); } } pushCommand(flags, emergencyStop = false) { /** with HCP, to send a command to the door driver (master) * you have to wait for the next slave status request from the master. * So push the command and await the promise to be resolved to confirm it was sent */ const payload = SerialHCPClient.createSlaveStatusPayload(flags, emergencyStop); return new Promise((resolve, reject) => { this.sendQueue.push({ payload, resolve, reject }); }); } } exports.SerialHCPClient = SerialHCPClient;