@pmouli/isy-matter-server
Version:
Service to expose an ISY device as a Matter Border router
660 lines (658 loc) • 29.3 kB
JavaScript
import WebSocket from 'faye-websocket';
import { writeFile } from 'fs';
import { Parser } from 'xml2js';
import { parseBooleans, parseNumbers } from 'xml2js/lib/processors.js';
import { XmlDocument } from 'xmldoc';
import axios from 'axios';
import { Categories } from './Definitions/Global/Categories.js';
import { DeviceFactory } from './Devices/DeviceFactory.js';
import { ELKAlarmPanelDevice } from './Devices/Elk/ElkAlarmPanelDevice.js';
import { ElkAlarmSensorDevice } from "./Devices/Elk/ElkAlarmSensorDevice.js";
import { InsteonBaseDevice } from './Devices/Insteon/InsteonBaseDevice.js';
import { InsteonOutletDevice } from './Devices/Insteon/InsteonDevice.js';
import { InsteonDimmableDevice } from './Devices/Insteon/InsteonDimmableDevice.js';
import { InsteonDimmerSwitchDevice } from './Devices/Insteon/InsteonDimmerSwitchDevice.js';
import { InsteonDoorWindowSensorDevice } from './Devices/Insteon/InsteonDoorWindowSensorDevice.js';
import { InsteonFanDevice, InsteonFanMotorDevice } from './Devices/Insteon/InsteonFanDevice.js';
import { InsteonKeypadRelayDevice } from "./Devices/Insteon/InsteonKeypadRelayDevice.js";
import { InsteonKeypadDimmerDevice } from "./Devices/Insteon/InsteonKeypadDimmerDevice.js";
import { InsteonLeakSensorDevice } from './Devices/Insteon/InsteonLeakSensorDevice.js';
import { InsteonLockDevice } from './Devices/Insteon/InsteonLockDevice.js';
import { InsteonMotionSensorDevice } from './Devices/Insteon/InsteonMotionSensorDevice.js';
import { InsteonRelayDevice } from './Devices/Insteon/InsteonRelayDevice.js';
import { InsteonThermostatDevice } from './Devices/Insteon/InsteonThermostatDevice.js';
import { ISYNodeDevice } from './ISYNode.js';
import { Family } from './Definitions/Global/Families.js';
import { EventType } from "./Events/EventType.js";
import { NodeType, Props, States, VariableType } from './ISYConstants.js';
import { ISYNode } from './ISYNode.js';
import { ISYScene } from './ISYScene.js';
import { ISYVariable } from './ISYVariable.js';
import { InsteonOnOffOutletDevice } from './Devices/Insteon/InsteonOnOffOutletDevice.js';
import { InsteonSmokeSensorDevice } from './Devices/Insteon/InsteonSmokeSensorDevice.js';
import { InsteonDimmerOutletDevice } from './Devices/Insteon/InsteonDimmerOutletDevice.js';
import { InsteonKeypadButtonDevice } from './Devices/Insteon/InsteonKeypadDevice.js';
import { EventEmitter } from 'events';
import { Logger, loggers, format } from 'winston';
import * as Utils from './Utils.js';
export { ISYScene, States, Family, VariableType, Categories, Props, ISYVariable, InsteonBaseDevice, InsteonOutletDevice, ISYNodeDevice as ISYDevice, InsteonKeypadDimmerDevice, InsteonKeypadRelayDevice, InsteonKeypadButtonDevice, InsteonDimmableDevice, InsteonFanDevice, InsteonFanMotorDevice, InsteonLeakSensorDevice, InsteonSmokeSensorDevice, InsteonDimmerOutletDevice, InsteonOnOffOutletDevice, InsteonLockDevice, InsteonThermostatDevice, InsteonDoorWindowSensorDevice, InsteonDimmerSwitchDevice, InsteonRelayDevice, InsteonMotionSensorDevice, ISYNode, NodeType, ElkAlarmSensorDevice, ELKAlarmPanelDevice, Utils };
const parser = new Parser({
explicitArray: false,
mergeAttrs: true,
attrValueProcessors: [parseNumbers, parseBooleans],
valueProcessors: [parseNumbers, parseBooleans],
tagNameProcessors: [(name) => name === 'st' ? '' : name]
});
export let Controls = {};
export class ISY extends EventEmitter {
deviceList = new Map();
deviceMap = new Map();
sceneList = new Map();
folderMap = new Map();
webSocket;
zoneMap = new Map();
protocol;
host;
port;
get address() {
return `${this.host}:${this.port}`;
}
id;
vendorName = "Universal Devices, Inc.";
productId = 5226;
productName = "eisy";
restlerOptions;
credentials;
variableList = new Map();
nodesLoaded = false;
wsprotocol = 'ws';
elkEnabled;
get isDebugEnabled() {
return this.logger?.isDebugEnabled();
}
displayNameFormat;
guardianTimer;
elkAlarmPanel;
logger;
lastActivity;
model;
serverVersion;
storagePath;
enableWebSocket;
configInfo;
static instance;
constructor(config, logger = new Logger(), storagePath) {
super();
this.enableWebSocket = config.enableWebSocket ?? true;
this.storagePath = storagePath ?? './';
this.displayNameFormat = config.displayNameFormat ?? '${location ?? folder} ${spokenName ?? name}';
this.host = config.host;
this.port = config.port;
this.credentials = {
username: config.username,
password: config.password
};
this.protocol = config.protocol;
this.wsprotocol = 'ws';
this.elkEnabled = config.elkEnabled ?? false;
this.nodesLoaded = false;
var fopts = format((info) => { info.message = JSON.stringify(info.message); return info; })({ label: 'ISY' });
this.logger = loggers.add('isy', { transports: logger.transports, level: logger.level, format: format.label({ label: 'ISY' }) });
this.guardianTimer = null;
if (this.elkEnabled) {
this.elkAlarmPanel = new ELKAlarmPanelDevice(this, 1);
}
ISY.instance = this;
}
[Symbol.dispose]() {
try {
this.webSocket.close();
}
catch (e) {
this.logger.error(`Error closing websocket: ${e.message}`);
}
}
emit(event, node) {
return super.emit(event, node);
}
on(event, listener) {
return super.on(event, listener);
}
async sendRequest(url, requestLogLevel = 'debug', responseLogLevel = 'debug', trailingSlash = true) {
url = `${this.protocol}://${this.address}/rest/${url}${trailingSlash ? '/' : ''}`;
this.logger.log(`Sending request: ${url}`, requestLogLevel);
try {
const response = await axios.get(url, { auth: { username: this.credentials.username, password: this.credentials.password } });
if(response.status === 200)
{
if (response.data) {
if (response.headers['content-type']?.includes('xml'))
return await parser.parseStringPromise(response.data).then((result) => {
if (this.checkForFailure(result)) {
// this.logger.info(`Error calling ISY: ${JSON.stringify(response)}`);
throw new Error(`Error sending request to ISY: ${JSON.stringify(result,2)}`);
}
this.logger.log(`Response: ${JSON.stringify(result,2)}`, responseLogLevel);
return result;
}, (reason) => {
this.logger.error(`Error sending request to ISY: ${JSON.stringify(reason,2)}`);
throw new Error(`Error sending request to ISY: ${JSON.stringify(reason,2)}`);
});
else if (response.headers['content-type']?.includes('json')) {
this.logger.log(`Response: ${JSON.stringify(response.data,2)}`, responseLogLevel);
return JSON.parse(response.data);
}
else {
this.logger.log(`Response: ${JSON.stringify({header: response.headers, status: `${response.statusText} (${response.status})`,data: response.data },null,2)}`, responseLogLevel);
return response.data;
}
}
}
this.logger.warn(`No data returned from ISY: ${JSON.stringify(response,2)}`);
}
catch (error) {
this.logger.error(`Error sending request to ISY: ${error}`);
throw new Error(`Error sending request to ISY: ${JSON.stringify(error,2)}`);
}
}
nodeChangedHandler(node, propertyName = null) {
const that = this;
if (this.nodesLoaded) {
// this.logger.info(`Node: ${node.address} changed`);
// if (this.changeCallback !== undefined && this.changeCallback !== null) {
// t//his.changeCallback(that, node, propertyName);
// }
}
}
getElkAlarmPanel() {
return this.elkAlarmPanel;
}
async loadNodes() {
try {
const result = await this.sendRequest('nodes');
if (this.isDebugEnabled)
writeFile(this.storagePath + '/ISYNodesDump.json', JSON.stringify(result), this.logger.error);
await this.readFolderNodes(result).catch(p => this.logger.error('Error Loading Folders', p));
await this.readDeviceNodes(result).catch(p => this.logger.error('Error Loading Devices', p));
await this.readSceneNodes(result).catch(p => this.logger.error('Error Loading Scenes', p));
return result;
}
catch (e) {
throw new Error(`Error loading nodes: ${e.message}`);
}
}
async readFolderNodes(result) {
this.logger.info('Loading Folder Nodes');
if (result?.nodes?.folder) {
for (const folder of result.nodes.folder) {
this.logger.info(`Loading Folder Node: ${JSON.stringify(folder)}`);
this.folderMap.set(folder.address, folder.name);
}
}
}
async readSceneNodes(result) {
this.logger.info('Loading Scene Nodes');
for (const scene of result.nodes?.group) {
if (scene.name === 'ISY' || scene.name === "IoX" || scene.name === 'Auto DR') {
continue;
} // Skip ISY & Auto DR Scenes
const newScene = new ISYScene(this, scene);
try {
await newScene.refreshNotes();
}
catch (e) {
this.logger.debug('No notes found.');
}
this.sceneList.set(newScene.address, newScene);
}
}
async readDeviceNodes(obj) {
this.logger.info('Loading Device Nodes');
for (const device of obj.nodes.node) {
this.logger.debug(`Loading Device Node: ${JSON.stringify(device)}`);
if (!this.deviceMap.has(device.pnode)) {
const address = device.address;
this.deviceMap[device.pnode] = {
address
};
}
else {
this.deviceMap[device.pnode].push(device.address);
}
let newDevice = null;
// let deviceTypeInfo = this.isyTypeToTypeName(device.type, device.address);
// this.logger.info(JSON.stringify(deviceTypeInfo));
const enabled = device.enabled ?? true;
const d = DeviceFactory.getDeviceDetails(device);
if (d.class) {
newDevice = new d.class(this, device);
newDevice.productName = d.name;
newDevice.model = `(${d.modelNumber}) ${d.name} v.${d.version}`;
newDevice.modelNumber = d.modelNumber;
newDevice.version = d.version;
}
if (enabled) {
if (newDevice === null) {
this.logger.warn(`Device type resolution failed for ${device.name} with type: ${device.type} and nodedef: ${device.nodeDefId}`);
newDevice = new ISYNodeDevice(this, device);
}
else if (newDevice !== null) {
if (d.unsupported) {
this.logger.warn('Device not currently supported: ' + JSON.stringify(device) + ' /n It has been mapped to: ' + d.class.name);
}
try {
await newDevice.refreshNotes();
}
catch (e) {
this.logger.debug('No notes found.');
}
// if (!newDevice.hidden) {
// }
// this.deviceList.push(newDevice);
}
else {
}
this.deviceList.set(newDevice.address, newDevice);
}
else {
this.logger.info(`Ignoring disabled device: ${device.name}`);
}
}
this.logger.info(`${this.deviceList.size} devices added.`);
}
loadElkNodes(result) {
const document = new XmlDocument(result);
const nodes = document
.childNamed('areas')
.childNamed('area')
.childrenNamed('zone');
for (let index = 0; index < nodes.length; index++) {
const id = nodes[index].attr.id;
const name = nodes[index].attr.name;
const alarmDef = nodes[index].attr.alarmDef;
const newDevice = new ElkAlarmSensorDevice(this, name, 1, id /*TODO: Handle CO Sensor vs. Door/Window Sensor */);
this.zoneMap[newDevice.zone] = newDevice;
}
}
loadElkInitialStatus(result) {
const p = new Parser({
explicitArray: false,
mergeAttrs: true
});
p.parseString(result, (err, res) => {
if (err) {
throw err;
}
for (const nodes of res.ae) {
this.elkAlarmPanel.setFromAreaUpdate(nodes);
}
for (const nodes of res.ze) {
const id = nodes.zone;
const zoneDevice = this.zoneMap[id];
if (zoneDevice !== null) {
zoneDevice.setFromZoneUpdate(nodes);
if (this.deviceList[zoneDevice.address] === null &&
zoneDevice.isPresent()) {
this.deviceList[zoneDevice.address] = zoneDevice;
// this.deviceIndex[zoneDevice.address] = zoneDevice;
}
}
}
});
}
finishInitialize(success) {
if (!this.nodesLoaded) {
this.nodesLoaded = true;
//initializeCompleted();
if (success) {
// if (this.elkEnabled) {
// this.deviceList[this.elkAlarmPanel.address] = this.elkAlarmPanel;
// }
if (this.enableWebSocket) {
this.guardianTimer = setInterval(this.guardian.bind(this), 60000);
this.initializeWebSocket();
}
}
}
}
guardian() {
const timeNow = new Date();
if (Number(timeNow) - Number(this.lastActivity) > 60000) {
this.logger.info('Guardian: Detected no activity in more then 60 seconds. Reinitializing web sockets');
this.initializeWebSocket();
}
}
variableChangedHandler(variable) {
this.logger.info(`Variable: ${variable.id} (${variable.type}) changed`);
}
checkForFailure(response) {
return (response === null ||
response instanceof Error ||
response.RestResponse !== undefined && response.RestResponse.status !== 200);
}
async loadVariables(type) {
const that = this;
return this.sendRequest(`vars/definitions/${type}`).then((result) => that.createVariables(type, result))
.then(() => that.sendRequest(`vars/get/${type}`)).then(that.setVariableValues.bind(that));
}
async loadConfig() {
try {
this.logger.info('Loading ISY Config');
const configuration = (await this.sendRequest('config')).configuration;
if (this.isDebugEnabled) {
writeFile(this.storagePath + '/ISYConfigDump.json', JSON.stringify(configuration), this.logger.error);
}
const controls = configuration.controls;
this.model = configuration.deviceSpecs.model;
this.serverVersion = configuration.app_version;
this.vendorName = configuration.deviceSpecs.make;
this.productId = configuration.product.id;
this.productName = configuration.product.desc;
this.id = configuration.root.id;
// TODO: Check Installed Features
// this.logger.info(result.configuration);
if (controls !== undefined) {
// this.logger.info(controls.control);
// var arr = new Array(controls.control);
for (const ctl of controls.control) {
// this.logger.info(ctl);
Controls[ctl.name] = ctl;
}
}
return configuration;
}
catch (e) {
throw Error(`Error Loading Config: ${e.message}`);
}
}
getVariableList() {
return this.variableList;
}
getVariable(type, id) {
const key = this.createVariableKey(type, id);
if (this.variableList.has(key)) {
return this.variableList[key];
}
return null;
}
createVariableKey(type, id) {
return `${type}:${id}`;
}
createVariables(type, result) {
for (const variable of result.CList.e) {
const id = Number(variable.id);
const name = variable.name;
const newVariable = new ISYVariable(this, id, name, type);
this.variableList.set(this.createVariableKey(type, id), newVariable);
}
}
setVariableValues(result) {
for (const vals of result.vars.var) {
const type = Number(vals.type);
const id = Number(vals.id);
const variable = this.getVariable(type, id);
if (variable) {
variable.init = vals.init;
variable.value = vals.val;
variable.lastChanged = new Date(vals.ts);
}
}
}
async refreshStatuses() {
try {
const that = this;
const result = await that.sendRequest('status');
if (that.isDebugEnabled) {
writeFile(that.storagePath + '/ISYStatusDump.json', JSON.stringify(result), this.logger.error);
}
this.logger.debug(result);
for (const node of result.nodes.node) {
this.logger.debug(node);
let device = that.getDevice(node.id);
if (device !== null && device !== undefined) {
let child = device.children.find(p => p.address === node.id);
if (child) {
//Case FanLinc where we treat the light as a child of the fan.
device = child;
}
if (Array.isArray(node.property)) {
for (let prop of node.property) {
device.local[prop.id] = device.convertFrom(prop.value, prop.uom);
device.formatted[prop.id] = prop.formatted;
device.uom[prop.id] = prop.uom;
device.logger(`Property ${Controls[prop.id].label} (${prop.id}) initialized to: ${device.local[prop.id]} (${device.formatted[prop.id]})`);
}
}
else if (node.property) {
device.local[node.property.id] = device.convertFrom(node.property.value, node.property.uom);
device.formatted[node.property.id] = node.property.formatted;
device.uom[node.property.id] = node.property.uom;
device.logger(`Property ${Controls[node.property.id].label} (${node.property.id}) initialized to: ${device.local[node.property.id]} (${device.formatted[node.property.id]})`);
}
}
;
}
}
catch (e) {
throw new Error(`Error refreshing statuses: ${JSON.stringify(e.message)}`);
}
}
async initialize() {
const that = this;
const options = {
username: this.credentials.username,
password: this.credentials.password
};
try {
await this.loadConfig();
await this.loadNodes();
await this.loadVariables(VariableType.Integer);
await this.loadVariables(VariableType.State);
await this.refreshStatuses().then(() => {
if (this.elkEnabled) {
// get(
// `${this.protocol}://${that.address}/rest/elk/get/topology`,
// options
// ).on('complete', (result: { message: string; }, response: any) => {
// if (that.checkForFailure(response)) {
// that.logger.info('Error loading from elk: ' + result.message);
// throw new Error(
// 'Unable to contact the ELK to get the topology'
// );
// } else {
// that.loadElkNodes(result);
// get(
// `${that.protocol}://${that.address}/rest/elk/get/status`,
// options
// ).on('complete', (result: { message: string; }, response: any) => {
// if (that.checkForFailure(response)) {
// that.logger.info(`Error:${result.message}`);
// throw new Error(
// 'Unable to get the status from the elk'
// );
// } else {
// that.loadElkInitialStatus(result);
// that.finishInitialize(true, initializeCompleted);
// }
// });
// }
// });
}
else {
that.finishInitialize(true);
}
});
}
catch (e) {
throw (e);
}
finally {
if (this.nodesLoaded !== true) {
that.finishInitialize(true);
}
}
return Promise.resolve(true);
}
async handleInitializeError(step, reason) {
this.logger.error(`Error initializing ISY (${step}): ${JSON.stringify(reason)}`);
return Promise.reject(reason);
}
handleWebSocketMessage(event) {
this.lastActivity = new Date();
parser.parseString(event.data, (err, res) => {
if (err) {
throw err;
}
const evt = res.Event;
if (evt === undefined || evt.control === undefined) {
return;
}
let actionValue = 0;
if (evt.action instanceof Object) {
actionValue = evt.action._;
}
else if (evt.action instanceof Number || evt.action instanceof String) {
actionValue = Number(evt.action);
}
const stringControl = Number(evt.control?.replace('_', ''));
switch (stringControl) {
case EventType.Elk:
if (actionValue === 2) {
this.elkAlarmPanel.handleEvent(event);
}
else if (actionValue === 3) {
const zeElement = evt.eventInfo.ze;
const zoneId = zeElement.zone;
const zoneDevice = this.zoneMap[zoneId];
if (zoneDevice !== null) {
if (zoneDevice.handleEvent(event)) {
this.nodeChangedHandler(zoneDevice);
}
}
}
break;
case EventType.Trigger:
if (actionValue === 6) {
const varNode = evt.eventInfo.var;
const id = varNode.id;
const type = varNode.type;
this.getVariable(type, id)?.handleEvent(evt);
}
break;
case EventType.Heartbeat:
this.logger.debug(`Received ${EventType[Number(stringControl)]} Signal from ISY: ${JSON.stringify(evt)}`);
break;
default:
if (evt.node !== '' && evt.node !== undefined && evt.node !== null) {
//
const impactedDevice = this.getDevice(evt.node);
if (impactedDevice !== undefined && impactedDevice !== null) {
impactedDevice.handleEvent(evt);
}
else {
this.logger.warn(`${EventType[stringControl]} Event for Unidentified Device: ${JSON.stringify(evt)}`);
}
}
else {
if (stringControl === EventType.NodeChanged) {
this.logger.info(`Received Node Change Event: ${JSON.stringify(evt)}. These are currently unsupported.`);
}
this.logger.debug(`${EventType[Number(stringControl)]} Event: ${JSON.stringify(evt)}`);
}
break;
}
});
}
initializeWebSocket() {
const that = this;
const auth = `Basic ${Buffer.from(`${this.credentials.username}:${this.credentials.password}`).toString('base64')}`;
this.logger.info(`Connecting to: ${this.wsprotocol}://${this.address}/rest/subscribe`);
if (this.webSocket) {
try {
this.webSocket.close();
}
catch (e) {
this.logger.warn(`Error closing existing websocket: ${e.message}`);
}
}
this.webSocket = new WebSocket.Client(`${this.wsprotocol}://${this.address}/rest/subscribe`, ['ISYSUB'], {
headers: {
Origin: 'com.universal-devices.websockets.isy',
Authorization: auth
},
ping: 10
});
this.lastActivity = new Date();
this.webSocket
.on('message', (event) => {
that.handleWebSocketMessage(event);
})
.on('error', (err, response) => {
that.logger.info(`Websocket subscription error: ${JSON.stringify(err.message)}`);
/// throw new Error('Error calling ISY' + err);
})
.on('fail', (data, response) => {
that.logger.info(`Websocket subscription failure: ${data}`);
throw new Error('Failed calling ISY');
})
.on('abort', () => {
that.logger.info('Websocket subscription aborted.');
throw new Error('Call to ISY was aborted');
})
.on('timeout', (ms) => {
that.logger.info(`Websocket subscription timed out after ${ms} milliseconds.`);
throw new Error('Timeout contacting ISY');
});
}
getDevice(address, parentsOnly = false) {
let s = this.deviceList.get(address);
if (!parentsOnly) {
if (s === null) {
s = this.deviceList[`${address.substr(0, address.length - 1)} 1`];
}
}
else {
while (s.parentAddress !== undefined &&
s.parentAddress !== s.address &&
s.parentAddress !== null) {
s = this.deviceList[s.parentAddress];
}
}
return s;
}
getScene(address) {
return this.sceneList.get(address);
}
async sendISYCommand(path) {
// const uriToUse = `${this.protocol}://${this.address}/rest/${path}`;
this.logger.info(`Sending command...${path}`);
return this.sendRequest(path);
}
async sendNodeCommand(node, command, parameters) {
let uriToUse = `nodes/${node.address}/cmd/${command}`;
if (parameters !== null && parameters !== undefined) {
if (typeof (parameters) == 'object') {
var q = parameters;
for (const paramName of Object.getOwnPropertyNames(q)) {
uriToUse += `/${paramName}/${q[paramName]}`;
}
//uriToUse += `/${q[((p : Record<string,number|number>) => `${p[]}/${p.paramValue}` ).join('/')}`;
}
else if (typeof (parameters) == 'number' || typeof (parameters) == 'string') {
uriToUse += `/${parameters}`;
}
}
this.logger.info(`${node.name}: sending ${command} command: ${uriToUse}`);
return this.sendRequest(uriToUse);
}
async sendGetVariable(id, type, handleResult) {
const uriToUse = `${this.protocol}://${this.address}/rest/vars/get/${type}/${id}`;
this.logger.info(`Sending ISY command...${uriToUse}`);
return this.sendRequest(uriToUse).then((p) => handleResult(p.val, p.init));
}
async sendSetVariable(id, type, value, handleResult) {
const uriToUse = `/rest/vars/set/${type}/${id}/${value}`;
this.logger.info(`Sending ISY command...${uriToUse}`);
return this.sendRequest(uriToUse);
}
}