@ljames8/hormann-hcp-client
Version:
Hormann Communication Protocol v1 garage door serial client
292 lines (291 loc) • 12.6 kB
JavaScript
;
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;