UNPKG

homebridge-texecom-full

Version:

A plugin for homebridge to integrate Texecom Premier Elite alarm systems into HomeKit

529 lines (453 loc) 23.3 kB
var Service; var Characteristic; var Accessory; 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 = []; // Creates an empty array, which can later grow dynamically var setByAlarm = false; module.exports = function (homebridge) { Accessory = homebridge.platformAccessory; Service = homebridge.hap.Service; Characteristic = homebridge.hap.Characteristic; homebridge.registerAccessory("homebridge-texecom-full", "Texecom", TexecomAccessory); homebridge.registerPlatform("homebridge-texecom-full", "Texecom", TexecomPlatform); } function TexecomPlatform(log, config) { this.log = new LogUtil( config.debug, config.name, log ); 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"]; } TexecomPlatform.prototype = { accessories: function (callback) { var zoneAccessories = []; for (var i = 0; i < this.zones.length; i++) { var zone = new TexecomAccessory(this.log, this.zones[i]); zoneAccessories.push(zone); } var zoneCount = zoneAccessories.length; var areaAccessories = []; for (var i = 0; i < this.areas.length; i++) { var area = new TexecomAccessory(this.log, this.areas[i]); areaAccessories.push(area); } var areaCount = areaAccessories.length; callback(zoneAccessories.concat(areaAccessories)); platform = this; function processData(data) { // Received data is a zone update if (S(data).startsWith('"Z')) { // Extract the data from the serial line received var zone_data = Number(S(S(data).between('Z')).left(4).s); // Extract the zone number that is being updated var updated_zone = Number(S(S(data).between('Z')).left(3).s); // Is the zone active? 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)) { // Set the security system state to "ALARM_TRIGGERED" stateValue = Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED; platform.log.log("Area " + areaAccessories[a].zone_number + " manual triggered"); // Log the state change platform.log.debug("Area match found, updating area status in HomeKit to " + stateValue); 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')) { // Extract the area number that is being updated 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; 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"); areas_armed = areas_armed.filter(value => value !== zpad(updated_area, 3)); break; case "A": //user is for my setup //my user 17 is full armed (remote) //my user on app is 254 //all the rest is night armed if (user == "17") { stateValue = Characteristic.SecuritySystemCurrentState.AWAY_ARM; platform.log.log("Area " + updated_area + " armed"); } else if (user == "25" || user == "254") { // Read the target state const targetState = areaAccessories[updated_area - 1].target_State; // Map the targetState to the corresponding current state value 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 254: Target state is " + targetState + ", updated stateValue is " + stateValue); } else { stateValue = Characteristic.SecuritySystemCurrentState.NIGHT_ARM; platform.log.log("Area " + updated_area + " night armed"); } if (stateValue == Characteristic.SecuritySystemCurrentState.AWAY_ARM) { // Find the index of the first null or undefined value const index = areas_armed.findIndex(value => value === null || value === undefined); if (index !== -1) { // Replace the value at that index areas_armed[index] = zpad(updated_area, 3); } else { // If no open slot, append to the end 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); break; } } } else { platform.log.debug("Unknown string from Texecom: " + S(data)); } } if (this.serial_device) { var SerialPort = serialport.SerialPort; serialPort = new SerialPort(this.serial_device, { baudrate: this.baud_rate, parser: serialport.parsers.readline("\n") }); serialPort.on("open", function () { platform.log.log("Serial port opened"); serialPort.on('data', function (data) { platform.log.debug("Serial data received: " + data); responseEmitter.emit('data', data); processData(data); }); }); this.texecomConnection = serialPort; } else if (this.ip_address) { try { connection = net.createConnection(platform.ip_port, platform.ip_address, function () { platform.log.log('Connected via IP'); }); } catch (err) { platform.log.error(err); } connection.setNoDelay(true); connection.on('data', function (data) { platform.log.debug("IP data received: " + data); responseEmitter.emit('data', data); processData(data); }); connection.on('end', function () { platform.log.log('IP connection ended'); }); connection.on('close', function () { platform.log.log('IP connection closed'); try { connection = net.createConnection(platform.ip_port, platform.ip_address, function () { platform.log.log('Re-connected after loss of connection'); }); } catch (err) { platform.log.error(err); } }); this.texecomConnection = connection; } else { this.log.log("Must set either serial_device or ip_address in configuration."); } } } function TexecomAccessory(log, config) { this.log = log; 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; if (config["area_type"] == "securitysystem") { this.target_State = Characteristic.SecuritySystemTargetState.DISARM; } try { if (Array.isArray(config["zones"])) { // If it's an array, apply zpad to each element this.zones = config["zones"].map(zone => zpad(zone, 3)); } else { // Otherwise, treat it as a single value this.zones = zpad(config["zones"], 3); } } catch (e) { //log.error('Error processing zones: ', e); } if (config["sn"]) { this.sn = config["sn"]; } else { var shasum = crypto.createHash('sha1'); shasum.update(this.zone_number/* || this.area_number*/); this.sn = shasum.digest('base64'); log.log('Computed SN: ' + this.sn); } } TexecomAccessory.prototype = { getServices: function () { const me = this; var service, changeAction; var informationService = new Service.AccessoryInformation(); informationService .setCharacteristic(Characteristic.Name, this.name) .setCharacteristic(Characteristic.Manufacturer, "Homebridge") .setCharacteristic(Characteristic.Model, "Texecom " + (this.accessoryType === "zone" ? "Zone" : "Area")) .setCharacteristic(Characteristic.SerialNumber, this.sn); switch (this.zone_type) { case "contact": service = new Service.ContactSensor(); changeAction = function (newState) { service.getCharacteristic(Characteristic.ContactSensorState) .setValue(newState ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED); }; break; case "motion": service = new Service.MotionSensor(); changeAction = function (newState) { service.getCharacteristic(Characteristic.MotionDetected) .setValue(newState); }; break; case "smoke": service = new Service.SmokeSensor(); changeAction = function (newState) { service.getCharacteristic(Characteristic.SmokeDetected) .setValue(newState ? Characteristic.ContactSensorState.SMOKE_DETECTED : Characteristic.ContactSensorState.SMOKE_NOT_DETECTED); }; break; case "carbonmonoxide": service = new Service.CarbonMonoxideSensor(); changeAction = function (newState) { service.getCharacteristic(Characteristic.CarbonMonoxideDetected) .setValue(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; // alarm triggered has no corresponding target state break; } service.getCharacteristic(Characteristic.SecuritySystemCurrentState).setValue(newState); if (targetState != null) { service.getCharacteristic(Characteristic.SecuritySystemTargetState).setValue(targetState); service.getCharacteristic(Characteristic.SecuritySystemTargetState).updateValue(targetState); } service.getCharacteristic(Characteristic.SecuritySystemCurrentState).updateValue(newState); me.log.debug("Set target state " + targetState + " and current state " + newState + " in response to notification from alarm"); }; // we don't know the alarm's state at startup, safer to assume disarmed: changeAction(Characteristic.SecuritySystemCurrentState.DISARMED); var area = this; service.getCharacteristic(Characteristic.SecuritySystemTargetState) .on('set', function (value, callback) { if (setByAlarm) { platform.log.debug("Not sending command to alarm for change to state " + value + " because the state change appears to have come from the alarm itself."); setByAlarm = false; return; } if (platform.udl != null) { areaTargetSecurityStateSet(platform, area, service, value, callback); } else { platform.log.debug("No UDL configured. Add your UDL to enable arm/disarm from HomeKit."); callback(new Error("No UDL configured")); } }); break; default: service = new Service.MotionSensor(); changeAction = function (newState) { service.getCharacteristic(Characteristic.MotionDetected) .setValue(newState); }; break; } this.changeHandler = function (status) { var newState = status; platform.log.debug("Dwell = " + this.dwell_time); if (!newState && this.dwell_time > 0) { this.dwell_timer = setTimeout(function () { changeAction(newState); }.bind(this), this.dwell_time); } else { if (this.dwell_timer) { clearTimeout(this.dwell_timer); } changeAction(newState); } platform.log.debug("Changing state with changeHandler to " + newState); }.bind(this); return [informationService, service]; } }; function areaTargetSecurityStateSet(platform, accessory, service, value, callback) { 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; // Home break; case Characteristic.SecuritySystemTargetState.AWAY_ARM: command = "A" + area_number; // Away arm break; case Characteristic.SecuritySystemTargetState.DISARM: command = "D" + area_number; // Disarm break; default: this.log.debug("Unknown target state: " + value); callback(new Error("Unknown target state")); return; } platform.log.debug("Sending arm/disarm command " + value + " to area " + accessory.zone_number); writeCommandAndWaitForOK(platform.texecomConnection, "W" + platform.udl) .then(() => writeCommandAndWaitForOK(platform.texecomConnection, command, 0)) .then(() => { // OK response from alarm is only indication that the target state has been reached 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("Setting current status of area " + accessory.zone_number + " to " + currentState + " because alarm responded OK"); service.getCharacteristic(Characteristic.SecuritySystemCurrentState) .updateValue(currentState); accessory.target_State = currentState; callback(); }) .catch((err) => { platform.log.debug("Callback with error " + err); callback(err); // Handle errors }); } 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) { platform.log.debug("Error writing to connection: " + err); reject(err); } else { platform.log.debug("Command sent: " + command); } }); setTimeout(() => { responseEmitter.removeListener('data', handleData); if (retryCount > 0) { platform.log.debug("Retrying command due to timeout, retries left: " + retryCount); writeCommandAndWaitForOK(connection, command, retryCount - 1).then(resolve).catch(reject); } else { reject(new Error("Timeout after retries")); } }, 2000); }); } function is_armed(area_number) { let isAreaArmed = areas_armed.some(value => value === area_number); return isAreaArmed; }