homebridge-texecom-full
Version:
A plugin for homebridge to integrate Texecom Premier Elite alarm systems into HomeKit
489 lines (416 loc) • 23.6 kB
JavaScript
var debug = require("debug")("TexecomAccessory");
var serialport = require("serialport");
var zpad = require("zpad");
var S = require('string');
var crypto = require("crypto");
var net = require('net');
const EventEmitter = require('events');
class ResponseEmitter extends EventEmitter { }
const responseEmitter = new ResponseEmitter();
const LogUtil = require('./util/logutil');
var areas_armed = [];
var setByAlarm = false;
// ─── Homebridge v2 entry point ──────────────────────────────────────────────
module.exports = (api) => {
api.registerPlatform("Texecom", TexecomPlatform);
};
// ─── Platform ────────────────────────────────────────────────────────────────
class TexecomPlatform {
constructor(log, config, api) {
this.log = new LogUtil(config.debug, config.name, log);
this.api = api;
this.hap = api.hap;
this.serial_device = config["serial_device"];
this.baud_rate = config["baud_rate"];
this.zones = config["zones"] || [];
this.areas = config["areas"] || [];
this.ip_address = config["ip_address"];
this.ip_port = config["ip_port"];
this.udl = config["udl"];
this.api.on('didFinishLaunching', () => {
this._setupAccessories();
});
}
// Required by v2 — called for each cached accessory; we don't cache, so no-op
configureAccessory(accessory) { }
_setupAccessories() {
const platform = this;
const { hap, api } = this;
const zoneAccessories = this.zones.map(z => new TexecomAccessory(this.log, z, hap));
const areaAccessories = this.areas.map(a => new TexecomAccessory(this.log, a, hap));
const zoneCount = zoneAccessories.length;
const areaCount = areaAccessories.length;
// Publish all accessories as external accessories (no cache needed)
// UUID is namespaced with type prefix to prevent collisions between a zone
// and an area that share the same zone_number / SN.
const publishAccessory = (acc, typePrefix) => {
acc._platform = platform; // inject platform ref for onSet handler
const uuid = hap.uuid.generate(`${typePrefix}:${acc.sn}`);
const hapAcc = new api.platformAccessory(acc.name, uuid);
const [, mainSvc] = acc.getServices();
// Populate the built-in AccessoryInformation service
hapAcc.getService(hap.Service.AccessoryInformation)
.setCharacteristic(hap.Characteristic.Manufacturer, "Homebridge")
.setCharacteristic(hap.Characteristic.Model, `Texecom ${typePrefix === "area" ? "Area" : "Zone"}`)
.setCharacteristic(hap.Characteristic.SerialNumber, acc.sn);
hapAcc.addService(mainSvc);
api.publishExternalAccessories("homebridge-texecom-full", [hapAcc]);
};
zoneAccessories.forEach(acc => publishAccessory(acc, "zone"));
areaAccessories.forEach(acc => publishAccessory(acc, "area"));
// ── Data processing ──────────────────────────────────────────────────
function processData(data) {
if (S(data).startsWith('"Z')) {
var zone_data = Number(S(S(data).between('Z')).left(4).s);
var updated_zone = Number(S(S(data).between('Z')).left(3).s);
var zone_active = S(zone_data).endsWith('1');
platform.log.debug(`Zone update received for zone ${updated_zone} active: ${zone_active}`);
for (var i = 0; i < zoneCount; i++) {
if (zoneAccessories[i].zone_number == updated_zone) {
platform.log.debug(`Zone match found, updating zone status in HomeKit to ${zone_active}`);
zoneAccessories[i].changeHandler(zone_active);
if (zone_active) {
for (var a = 0; a < areaCount; a++) {
try {
areaAccessories[a].zones.forEach(zone => {
if (zone == zpad(updated_zone, 3) && is_armed(areaAccessories[a].zone_number)) {
var stateValue = hap.Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED;
platform.log.log(`Area ${areaAccessories[a].zone_number} manual triggered`);
setByAlarm = true;
areaAccessories[a].changeHandler(stateValue);
}
});
} catch (e) {
console.debug(`Error processing zones for area ${a}: Please add zones under area.`);
}
}
}
break;
}
}
} else if (S(data).startsWith('"A') || S(data).startsWith('"D') || S(data).startsWith('"L')) {
const Characteristic = hap.Characteristic;
var updated_area = Number(S(S(data).substring(2, 5)));
var status = S(data).substring(1, 2);
var user = S(data).substring(5, 7);
var stateValue;
const armedByUser = user;
switch (String(status)) {
case "L":
stateValue = Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED;
platform.log.log(`Area ${updated_area} triggered`);
break;
case "D":
stateValue = Characteristic.SecuritySystemCurrentState.DISARMED;
platform.log.log(`Area ${updated_area} disarmed by User ${user}`);
areas_armed = areas_armed.filter(v => v !== zpad(updated_area, 3));
break;
case "A":
if (user == "17") {
stateValue = Characteristic.SecuritySystemCurrentState.AWAY_ARM;
platform.log.log(`Area ${updated_area} armed (away) by User ${user}`);
} else if (user == "25" || user == "254") {
const targetState = areaAccessories[updated_area - 1].target_State;
switch (targetState) {
case Characteristic.SecuritySystemTargetState.AWAY_ARM:
stateValue = Characteristic.SecuritySystemCurrentState.AWAY_ARM; break;
case Characteristic.SecuritySystemTargetState.STAY_ARM:
stateValue = Characteristic.SecuritySystemCurrentState.STAY_ARM; break;
case Characteristic.SecuritySystemTargetState.NIGHT_ARM:
stateValue = Characteristic.SecuritySystemCurrentState.NIGHT_ARM; break;
case Characteristic.SecuritySystemTargetState.DISARM:
stateValue = Characteristic.SecuritySystemCurrentState.DISARMED; break;
default:
platform.log.error(`Unknown target state: ${targetState}`);
return;
}
platform.log.log(`User ${user}: Target state ${targetState} → stateValue ${stateValue}`);
} else {
stateValue = Characteristic.SecuritySystemCurrentState.NIGHT_ARM;
platform.log.log(`Area ${updated_area} armed (night) by User ${user}`);
}
if (stateValue == Characteristic.SecuritySystemCurrentState.AWAY_ARM) {
const idx = areas_armed.findIndex(v => v === null || v === undefined);
if (idx !== -1) {
areas_armed[idx] = zpad(updated_area, 3);
} else {
areas_armed.push(zpad(updated_area, 3));
}
}
break;
default:
platform.log.log(`Unknown status letter ${status}`);
return;
}
for (var i = 0; i < areaCount; i++) {
if (areaAccessories[i].zone_number == updated_area) {
platform.log.debug(`Area match found, updating area status in HomeKit to ${stateValue}`);
setByAlarm = true;
areaAccessories[i].changeHandler(stateValue, armedByUser);
break;
}
}
} else {
platform.log.debug(`Unknown string from Texecom: ${S(data)}`);
}
}
// ── IP connection with auto-reconnect ────────────────────────────────
function setupConnection() {
platform.log.log('Attempting connection to Texecom...');
var connection = net.createConnection(platform.ip_port, platform.ip_address);
connection.setNoDelay(true);
connection.on('connect', () => platform.log.log('Connected via IP'));
connection.on('data', function (data) {
platform.log.debug(`IP data received: ${data}`);
responseEmitter.emit('data', data);
processData(data);
});
connection.on('error', (err) => {
platform.log.error('IP connection error:', err.message);
});
connection.on('close', () => {
platform.log.error('IP connection closed. Reconnecting in 10s...');
setTimeout(() => setupConnection(), 10000);
});
connection.on('end', () => platform.log.log('IP connection ended'));
platform.texecomConnection = connection;
}
// ── Connect via serial or IP ─────────────────────────────────────────
if (this.serial_device) {
// serialport v12: baudRate (camelCase) + separate ReadlineParser
const { SerialPort } = require('serialport');
const { ReadlineParser } = require('@serialport/parser-readline');
const sp = new SerialPort({ path: this.serial_device, baudRate: this.baud_rate });
const parser = sp.pipe(new ReadlineParser({ delimiter: '\n' }));
sp.on("open", () => platform.log.log("Serial port opened"));
sp.on("error", (err) => platform.log.error("Serial port error:", err.message));
parser.on('data', function (data) {
platform.log.debug(`Serial data received: ${data}`);
responseEmitter.emit('data', data);
processData(data);
});
platform.texecomConnection = sp;
} else if (this.ip_address) {
setupConnection();
} else {
this.log.log("Must set either serial_device or ip_address in configuration.");
}
}
}
// ─── Accessory ───────────────────────────────────────────────────────────────
function TexecomAccessory(log, config, hap) {
this.log = log;
this.hap = hap;
this.zone_number = zpad(config["zone_number"] || config["area_number"], 3);
this.name = config["name"];
this.zone_type = config["zone_type"] || config["area_type"] || "motion";
this.dwell_time = config["dwell"] || 0;
this.dwell_timer = null;
if (config["area_type"] == "securitysystem") {
this.target_State = hap.Characteristic.SecuritySystemTargetState.DISARM;
}
try {
if (Array.isArray(config["zones"])) {
this.zones = config["zones"].map(zone => zpad(zone, 3));
} else {
this.zones = zpad(config["zones"], 3);
}
} catch (e) { /* no zones */ }
if (config["sn"]) {
this.sn = config["sn"];
} else {
const shasum = crypto.createHash('sha1');
shasum.update(this.zone_number);
this.sn = shasum.digest('base64');
log.log(`Computed SN: ${this.sn}`);
}
}
TexecomAccessory.prototype = {
getServices: function () {
const { Service, Characteristic } = this.hap;
const me = this;
var service, changeAction;
// informationService is returned but the platform populates the
// built-in one on the platformAccessory; this keeps the shape identical
var informationService = new Service.AccessoryInformation();
informationService
.setCharacteristic(Characteristic.Manufacturer, "Homebridge")
.setCharacteristic(Characteristic.Model, `Texecom ${this.zone_type === "securitysystem" ? "Area" : "Zone"}`)
.setCharacteristic(Characteristic.SerialNumber, this.sn);
switch (this.zone_type) {
case "contact":
service = new Service.ContactSensor();
changeAction = function (newState) {
service.getCharacteristic(Characteristic.ContactSensorState)
.updateValue(newState
? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
: Characteristic.ContactSensorState.CONTACT_DETECTED);
};
break;
case "smoke":
service = new Service.SmokeSensor();
changeAction = function (newState) {
service.getCharacteristic(Characteristic.SmokeDetected)
.updateValue(newState
? Characteristic.SmokeDetected.SMOKE_DETECTED
: Characteristic.SmokeDetected.SMOKE_NOT_DETECTED);
};
break;
case "carbonmonoxide":
service = new Service.CarbonMonoxideSensor();
changeAction = function (newState) {
service.getCharacteristic(Characteristic.CarbonMonoxideDetected)
.updateValue(newState
? Characteristic.CarbonMonoxideDetected.CO_LEVELS_ABNORMAL
: Characteristic.CarbonMonoxideDetected.CO_LEVELS_NORMAL);
};
break;
case "securitysystem":
service = new Service.SecuritySystem();
changeAction = function (newState) {
var targetState;
switch (newState) {
case Characteristic.SecuritySystemCurrentState.NIGHT_ARM:
targetState = Characteristic.SecuritySystemTargetState.NIGHT_ARM; break;
case Characteristic.SecuritySystemCurrentState.AWAY_ARM:
targetState = Characteristic.SecuritySystemTargetState.AWAY_ARM; break;
case Characteristic.SecuritySystemCurrentState.STAY_ARM:
targetState = Characteristic.SecuritySystemTargetState.STAY_ARM; break;
case Characteristic.SecuritySystemCurrentState.DISARMED:
targetState = Characteristic.SecuritySystemTargetState.DISARM; break;
default:
targetState = null; break;
}
if (targetState != null) {
service.getCharacteristic(Characteristic.SecuritySystemTargetState).updateValue(targetState);
}
service.getCharacteristic(Characteristic.SecuritySystemCurrentState).updateValue(newState);
me.log.debug(`Set target state ${targetState} and current state ${newState}`);
};
// Safe default on startup
changeAction(Characteristic.SecuritySystemCurrentState.DISARMED);
var area = this;
// v2: onSet returns a Promise; deprecated on('set', cb) removed
service.getCharacteristic(Characteristic.SecuritySystemTargetState)
.onSet(function (value) {
return new Promise((resolve, reject) => {
if (setByAlarm) {
me.log.debug(`Ignoring set — state change came from alarm itself.`);
setByAlarm = false;
resolve();
return;
}
// platform reference is injected after construction
const platform = me._platform;
if (platform && platform.udl != null) {
areaTargetSecurityStateSet(platform, area, service, value,
(err) => err ? reject(err) : resolve());
} else {
me.log.debug("No UDL configured. Add UDL to enable arm/disarm from HomeKit.");
reject(new Error("No UDL configured"));
}
});
});
break;
default: // motion (and fallback)
service = new Service.MotionSensor();
changeAction = function (newState) {
service.getCharacteristic(Characteristic.MotionDetected).updateValue(newState);
};
break;
}
this.changeHandler = function (status) {
const newState = status;
me.log.debug(`Dwell = ${me.dwell_time}`);
if (!newState && me.dwell_time > 0) {
me.dwell_timer = setTimeout(() => changeAction(newState), me.dwell_time);
} else {
if (me.dwell_timer) clearTimeout(me.dwell_timer);
changeAction(newState);
}
};
return [informationService, service];
}
};
// ─── Arm/disarm command ───────────────────────────────────────────────────────
function areaTargetSecurityStateSet(platform, accessory, service, value, callback) {
const { Characteristic } = platform.hap;
const hexMapping = {
'1': 0x01, '2': 0x02, '3': 0x04, '4': 0x08,
'5': 0x10, '6': 0x20, '7': 0x40, '8': 0x80
};
var area_number = String.fromCharCode(parseInt(hexMapping[parseInt(accessory.zone_number, 10)], 16));
var command;
switch (value) {
case Characteristic.SecuritySystemTargetState.NIGHT_ARM:
case Characteristic.SecuritySystemTargetState.STAY_ARM:
command = `Y${area_number}`; break;
case Characteristic.SecuritySystemTargetState.AWAY_ARM:
command = `A${area_number}`; break;
case Characteristic.SecuritySystemTargetState.DISARM:
command = `D${area_number}`; break;
default:
platform.log.debug(`Unknown target state: ${value}`);
callback(new Error("Unknown target state"));
return;
}
platform.log.debug(`Sending command ${value} to area ${accessory.zone_number}`);
writeCommandAndWaitForOK(platform.texecomConnection, `W${platform.udl}`)
.then(() => writeCommandAndWaitForOK(platform.texecomConnection, command, 0))
.then(() => {
var currentState;
switch (value) {
case Characteristic.SecuritySystemTargetState.NIGHT_ARM:
currentState = Characteristic.SecuritySystemCurrentState.NIGHT_ARM; break;
case Characteristic.SecuritySystemTargetState.AWAY_ARM:
currentState = Characteristic.SecuritySystemCurrentState.AWAY_ARM; break;
case Characteristic.SecuritySystemTargetState.STAY_ARM:
currentState = Characteristic.SecuritySystemCurrentState.STAY_ARM; break;
case Characteristic.SecuritySystemTargetState.DISARM:
currentState = Characteristic.SecuritySystemCurrentState.DISARMED; break;
default:
platform.log.debug(`Unknown target alarm state ${value}`);
callback(new Error("Unknown target state"));
return;
}
platform.log.debug(`Area ${accessory.zone_number} → state ${currentState}`);
service.getCharacteristic(Characteristic.SecuritySystemCurrentState).updateValue(currentState);
accessory.target_State = currentState;
callback();
})
.catch(err => {
platform.log.debug(`Command error: ${err}`);
callback(err);
});
}
// ─── Command writer ───────────────────────────────────────────────────────────
function writeCommandAndWaitForOK(connection, command, retryCount = 1) {
return new Promise((resolve, reject) => {
function handleData(data) {
if (data.toString().trim() === 'OK') {
responseEmitter.removeListener('data', handleData);
resolve();
}
}
responseEmitter.on('data', handleData);
connection.write(`\\${command}/`, function (err) {
if (err) {
responseEmitter.removeListener('data', handleData);
reject(err);
}
});
setTimeout(() => {
responseEmitter.removeListener('data', handleData);
if (retryCount > 0) {
writeCommandAndWaitForOK(connection, command, retryCount - 1)
.then(resolve).catch(reject);
} else {
reject(new Error("Timeout after retries"));
}
}, 2000);
});
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function is_armed(area_number) {
return areas_armed.some(v => v === area_number);
}