@pmouli/isy-matter-server
Version:
Service to expose an ISY device as a Matter Border router
1,053 lines (934 loc) • 35.5 kB
text/typescript
import axios, { type AxiosRequestConfig } from 'axios';
import chalk from 'chalk';
import { EventEmitter } from 'events';
import { format, Logger, loggers } from 'winston';
import WebSocket from 'ws';
import { type ParserOptions, Parser } from 'xml2js';
import { parseBooleans, parseNumbers } from 'xml2js/lib/processors.js';
import { DeviceFactory } from './Devices/DeviceFactory.js';
import { ELKAlarmPanelDevice } from './Devices/Elk/ElkAlarmPanelDevice.js';
import { ElkAlarmSensorDevice } from './Devices/Elk/ElkAlarmSensorDevice.js';
import { Message } from './ISY.js';
import { EventType } from './Events/EventType.js';
import { ISYDevice } from './ISYDevice.js';
import { ISYNode } from './ISYNode.js';
import { ISYVariable } from './ISYVariable.js';
import type { NodeInfo } from './Model/NodeInfo.js';
import { ISYScene } from './Scenes/ISYScene.js';
import { VariableType } from './VariableType.js';
import * as Utils from './Utils.js';
import { X2jOptions, XMLParser } from 'fast-xml-parser';
import type { ClientRequestArgs } from 'http';
import path from 'path';
import type { JsonPrimitive } from 'type-fest';
import { Family, UnitOfMeasure } from './Definitions/index.js';
import { GenericNode } from './Devices/GenericNode.js';
import { ISYError } from './ISYError.js';
import type { Config } from './Model/Config.js';
import { emphasize, isPrimitive, writeDebugFile } from './Utils.js';
type InitStep = 'config' | 'loadNodes' | 'readFolders' | 'readDevices' | 'readScenes' | 'variables' | 'websocket' | 'refreshStatuses' | 'initialize';
class ISYInitializationError extends ISYError {
step: InitStep;
constructor(message: string, step: InitStep);
constructor(error: Error, step: InitStep);
constructor(messageOrError: string | Error, step: InitStep) {
super(messageOrError as any);
this.name = 'ISYInitializationError';
this.step = step;
}
}
const defaultParserOptions: ParserOptions = {
explicitArray: false,
mergeAttrs: true,
attrValueProcessors: [parseNumbers, parseBooleans],
valueProcessors: [parseNumbers, parseBooleans],
tagNameProcessors: [(tagName) => (tagName === 'st' || tagName === 'cmd' || tagName === 'nodeDef' ? '' : tagName)]
};
const defaultXMLParserOptions: X2jOptions = {
parseAttributeValue: true,
allowBooleanAttributes: true,
attributeNamePrefix: '',
attributesGroupName: false,
ignoreAttributes: false,
// updateTag(tagName, jPath, attrs) {
// //if(tagName === 'st' || tagName === 'cmd' || tagName === 'nodeDef')
// // return false;
// //return tagName;
// },
textNodeName: '_',
commentPropName: '$comment',
cdataPropName: '$cdata',
ignoreDeclaration: true,
tagValueProcessor: (tagName, tagValue, jPath, hasAttributes, isLeafNode) => {
if (tagValue === '') return null;
return tagValue;
},
isArray(tagName, jPath, isLeafNode, isAttribute) {
if (tagName === 'property') return true;
return false;
}
};
axios.defaults.transitional.forcedJSONParsing = false;
const parser = new Parser(defaultParserOptions);
export let Controls = {};
interface ISYConfig {
// #region Properties (8)
displayNameFormat?: string;
elkEnabled?: boolean;
enableWebSocket?: boolean;
host: string;
password: string;
port: number;
protocol: 'http' | 'https';
username: string;
socketPath?: string;
axiosOptions?: AxiosRequestConfig;
webSocketOptions?: WebSocket.ClientOptions & ClientRequestArgs & { guardianInterval?: number };
apiOnly: boolean;
logUnsubscribedNodeEvents?: boolean;
// #endregion Properties (8)
}
export class ISY extends EventEmitter<{ nodeAdded: [ISYNode<any>]; initializeCompleted: [] }> implements Disposable, AsyncDisposable {
// #region Properties (30)
public readonly credentials: { username: string; password: string };
public readonly devices: Map<string, ISYDevice<any, any, any, any>> = new Map();
public readonly deviceMap: Map<string, string[]> = new Map();
public readonly displayNameFormat: string;
public readonly elkEnabled: boolean;
public readonly enableWebSocket: boolean;
public readonly folderMap: Map<string, string> = new Map();
public readonly host: string;
public readonly nodeMap: Map<string, ISYNode<any, any, any, any>> = new Map();
public readonly port: number;
public readonly protocol: string;
public readonly sceneList: Map<string, ISYScene> = new Map();
public readonly storagePath: string;
public readonly variableList: Map<string, ISYVariable<VariableType>> = new Map();
public readonly wsprotocol: 'ws' | 'wss' = 'ws';
public readonly zoneMap: Map<string, ElkAlarmSensorDevice> = new Map();
public static instance: ISY;
public configInfo: Config;
public readonly config: ISYConfig;
public elkAlarmPanel: ELKAlarmPanelDevice;
public guardianTimer: NodeJS.Timeout;
public id: string;
public lastActivity: any;
public logger: Logger;
public model: any;
public nodesLoaded: boolean = false;
public productId = 5226;
public productName = 'eisy';
public firmwareVersion: any;
public vendorName = 'Universal Devices, Inc.';
public webSocket: WebSocket;
public apiVersion: string;
socketPath: string;
xmlParser: XMLParser;
public axiosOptions: AxiosRequestConfig;
guardianInterval: number;
public logUnsubscribedNodeEvents: boolean = false;
public static async create(config: ISYConfig, logger?: Logger, storagePath?: string): Promise<ISY> {
if (!config.apiOnly) {
if (!ISY.instance) {
ISY.instance = new ISY(config, logger, storagePath);
await ISY.instance.initialize();
}
return ISY.instance;
}
return new ISY(config, logger, storagePath);
}
// #endregion Properties (30)
// #region Constructors (1)
private constructor(config: ISYConfig, logger: Logger = new Logger(), storagePath?: string) {
super();
this.config = config;
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.guardianInterval = config.webSocketOptions?.guardianInterval ?? 60000;
this.credentials = {
username: config.username,
password: config.password
};
this.protocol = config.protocol;
this.wsprotocol = config.protocol === 'https' ? 'wss' : 'ws';
this.socketPath = config.socketPath;
this.axiosOptions = {
validateStatus: (status) => status >= 200 && status < 300
};
if (this.socketPath) {
this.axiosOptions.socketPath = this.socketPath;
this.axiosOptions.baseURL = 'http://dummy/'; //Workaround for Bug with Axios 1.7.5+
} else {
(this.axiosOptions.baseURL = `${this.protocol}://${this.host}:${this.port}`), (this.axiosOptions.auth = { username: this.credentials.username, password: this.credentials.password });
}
this.webSocketOptions = { origin: 'com.universal-devices.websockets.isy' };
if (this.socketPath) {
this.webSocketOptions.socketPath = this.socketPath;
} else {
this.webSocketOptions.auth = `${this.credentials.username}:${this.credentials.password}`;
}
this.xmlParser = new XMLParser({ ...defaultXMLParserOptions });
//this.elkEnabled = config.elkEnabled ?? false;
this.nodesLoaded = false;
this.logger = loggers.add('isy', {
transports: logger.transports,
levels: logger.levels,
format: format.label({ label: chalk.white.bold('ISY') })
});
this.guardianTimer = null;
if (this.elkEnabled) {
this.elkAlarmPanel = new ELKAlarmPanelDevice(this, 1);
}
//ISY.instance = this;
}
[Symbol.dispose](): void {
if (ISY.instance === this) {
ISY.instance = null;
}
this.close();
this.logger.info(emphasize('ISY instance disposed.'));
}
async [Symbol.asyncDispose](): Promise<void> {
return new Promise(async (resolve) => {
this.logger.on('close', () => resolve());
try {
const p = new Promise<void>((resolve) =>
this.webSocket.once('close', (x, y) => {
resolve();
})
);
if (this.guardianTimer) {
clearInterval(this.guardianTimer);
this.guardianTimer = null;
}
this.webSocket?.close();
await p;
this.logger.info('WebSocket connection closed during async dispose.');
} catch (e) {
this.logger.error(`Error during async dispose: ${e.message}`);
} finally {
if (ISY.instance === this) {
ISY.instance = null;
}
this.logger.info(emphasize('ISY instance asynchronously disposed.'));
if (!this.logger.closed) {
this.logger.close();
} else {
resolve();
}
}
});
}
/*[Symbol.asyncDispose](): PromiseLike<void> {
this.webSocket?.close();
return Promise.resolve();
}*/
// #endregion Constructors (1)
// #region Public Getters And Setters (2)
public get address() {
return `${this.host}:${this.port}`;
}
public get isDebugEnabled() {
return this.logger?.isDebugEnabled();
}
// #endregion Public Getters And Setters (2)
// #region Public Methods (24)
/*[Symbol.dispose](): void {
if (ISY.instance === this) {
ISY.instance = null;
}
this.close();
}*/
/*public override emit(event: 'InitializeCompleted' | 'NodeAdded' | 'NodeRemoved' | 'NodeChanged', node?: ISYNode<any, any, any, any>): boolean {
return super.emit(event, node);
}*/
public getDevice<T extends ISYDevice<any, any, any, any> = ISYDevice<any, any, any, any>>(address: string, parentsOnly = false): T {
let s = this.devices.get(address);
if (!parentsOnly) {
if (s === null) {
s = this.devices[`${address.substr(0, address.length - 1)} 1`];
}
} else {
while (s.parentAddress !== undefined && s.parentAddress !== s.address && s.parentAddress !== null) {
s = this.devices[s.parentAddress];
}
}
return s as T;
}
public getElkAlarmPanel() {
return this.elkAlarmPanel;
}
public getNode<T extends ISYNode<any, any, any, any> = ISYNode<any, any, any, any>>(address: string, parentsOnly = false): T {
let s = this.nodeMap.get(address);
if (!parentsOnly) {
if (s === null) {
s = this.nodeMap[`${address.substr(0, address.length - 1)} 1`];
}
} else {
while (s.parentAddress !== undefined && s.parentAddress !== s.address && s.parentAddress !== null) {
s = this.nodeMap[s.parentAddress];
}
}
return s as T;
}
public getScene(address: string) {
return this.sceneList.get(address);
}
public getVariable<P extends VariableType>(type: P, id: number): ISYVariable<P> {
const key = this.#createVariableKey(type, id);
if (this.variableList.has(key)) {
return this.variableList[key];
}
return null;
}
public getVariableList() {
return this.variableList;
}
public async handleInitializeError(step: string, reason: any): Promise<any> {
this.logger.error(`Error initializing ISY (${step}): ${Utils.logStringify(reason)}`);
return Promise.reject(reason);
}
public handleWebSocketMessage(event: { data: any }) {
this.lastActivity = Date.now();
let that = this;
parser.parseString(event.data, (err: any, res: { Event: Message<any> }) => {
if (err) {
that.logger.error(`Error parsing ISY WebSocket message: ${err} - ${event.data}`);
return;
}
try {
const evt = res?.Event;
if (evt === undefined || evt.control === undefined) {
return;
}
let actionValue = 0;
if (evt.action instanceof Object) {
actionValue = Number(evt.action._);
} else if (typeof evt.action === 'string') {
actionValue = Number(evt.action);
}
const stringControl = Number((evt.control as string)?.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[EventType.Heartbeat]} Signal from ISY: ${JSON.stringify(evt)}`);
break;
case EventType.SystemStatusChanged:
case EventType.ProgressReport:
this.logger.silly(`${EventType[stringControl]} Message: ${JSON.stringify(evt)}`);
break;
default:
if (evt.node !== '' && evt.node !== undefined && evt.node !== null) {
//
const impactedNode = this.getNode(evt.node);
if (impactedNode !== undefined && impactedNode !== null) {
try {
impactedNode.handleEvent(evt);
} catch (e) {
this.logger.error(`Error handling message for ${impactedNode.name}: ${e.message}`);
}
} else {
this.logger.silly(`${stringControl} Message for Unidentified Device: ${JSON.stringify(evt)}`);
}
} else {
/*else if (stringControl === EventType.NodeChanged) {
this.logger.debug(`Received Node Change Event: ${JSON.stringify(evt)}. These are currently unsupported.`);*/
this.logger.debug(`${EventType[stringControl]} Message: ${JSON.stringify(evt)}`);
}
break;
}
} catch (e) {
this.logger.error(`Error handling WebSocket message: ${e.message} - ${JSON.stringify(event)}`);
}
});
}
public async initialize(): Promise<boolean> {
const that = this;
try {
await this.loadConfig();
await this.loadNodes();
await this.refreshStatuses();
await this.loadVariables(VariableType.Integer);
await this.loadVariables(VariableType.State);
await this.#finishInitialize(true);
return true;
} catch (e) {
if (e instanceof ISYInitializationError) {
if (e.step === 'variables') {
this.logger.warn(`Error loading variables: ${e.message}`);
await this.#finishInitialize(true);
return true;
}
this.logger.error(`Error initializing ISY during (${e.step}): ${e.message}`);
return false;
} else {
this.logger.error(`Error initializing ISY: ${e.message}`);
return false;
}
} finally {
if (this.nodesLoaded !== true) {
await that.#finishInitialize(false);
}
}
}
webSocketOptions: WebSocket.ClientOptions & ClientRequestArgs;
public async initializeWebSocket() {
if (!this.enableWebSocket) {
this.logger.warn('WebSocket is disabled. Skipping initialization.');
return;
}
try {
const that = this;
//const auth = `Basic ${Buffer.from(`${this.credentials.username}:${this.credentials.password}`).toString('base64')}`;
let address = `${this.wsprotocol}://${this.address}/rest/subscribe`;
if (this.socketPath) {
address = `ws+unix:${this.socketPath}:/rest/subscribe`;
}
this.logger.info(`Opening webSocket: ${address}`);
this.logger.info('Using the following websocket options: ' + JSON.stringify(this.webSocketOptions));
if (this.webSocket) {
try {
if (this.webSocket.readyState === WebSocket.OPEN) this.webSocket.close();
this.webSocket = null;
} catch (e) {
this.logger.warn(`Error closing existing websocket: ${e.message}`);
}
}
let p = new Promise<WebSocket>((resolve, reject) => {
let webSocket = new WebSocket(`${address}`, ['ISYSUB'], this.webSocketOptions);
//this.webSocket.onmessage = (event) => {this.handleWebSocketMessage()
webSocket
.on('open', () => {
this.logger.info(emphasize('Websocket subscription open'));
this.guardianTimer = setTimeout(async () => {
this.logger.warn('WebSocket Guardian Timer expired. Closing WebSocket.');
webSocket.terminate();
this.guardianTimer = null;
await this.initializeWebSocket();
}, this.guardianInterval ?? 60000);
resolve(webSocket);
})
.on('message', (data, b) => {
that.logger.silly(`Received message: ${Utils.logStringify(data, 1)}`);
this.guardianTimer.refresh();
that.handleWebSocketMessage({ data: data });
})
.on('error', (err: any, response: any) => {
that.logger.warn(`Websocket subscription error: ${err}`);
reject(new ISYInitializationError('Websocket subscription error', 'websocket'));
})
.on('fail', (data: string, response: any) => {
that.logger.warn(`Websocket subscription failure: ${data}`);
reject(new Error('Websocket subscription failure'));
})
.on('abort', () => {
that.logger.warn('Websocket subscription aborted.');
throw new Error('Websocket subscription aborted.');
})
.on('timeout', (ms: string) => {
that.logger.warn(`Websocket subscription timed out after ${ms} milliseconds.`);
reject(new Error('Timeout contacting ISY'));
//throw new Error('Timeout contacting ISY');
})
.on('close', (code: number, reason: string) => {
that.logger.info(emphasize(`Websocket subscription closed: ${code} - ${reason}`));
if (this.guardianTimer) {
clearTimeout(this.guardianTimer);
}
this.guardianTimer = null;
if (code !== 1005) {
this.logger.warn(`WebSocket closed with code ${code}: ${reason}. Forcing to terminate.`);
if (webSocket) {
webSocket.terminate();
}
}
if (webSocket) {
webSocket.removeAllListeners();
}
});
});
this.webSocket = await p;
} catch (e) {
throw new ISYInitializationError(e, 'websocket');
}
}
public async loadConfig(): Promise<any> {
try {
this.logger.info('Loading ISY Config');
const configuration = (await this.sendRequest('config')).configuration as Config;
if (this.isDebugEnabled) {
writeDebugFile(JSON.stringify(configuration), 'ISYConfig.json', this.logger, this.storagePath);
}
const controls = configuration.controls;
this.model = configuration.deviceSpecs.model;
this.firmwareVersion = configuration.app_full_version;
this.vendorName = configuration.deviceSpecs.make;
this.productId = configuration.product.id;
this.productName = configuration.product.desc;
this.id = configuration.root.id;
// 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 new ISYInitializationError(e, 'config');
}
}
public async loadNodes(): Promise<any> {
try {
this.logger.info('Loading ISY Nodes');
const result = await this.sendRequest('nodes');
if (this.isDebugEnabled) await writeDebugFile(JSON.stringify(result), 'ISYNodes.json', this.logger, this.storagePath);
await this.#readFolderNodes(result);
await this.#readDeviceNodes(result);
await this.#readSceneNodes(result);
return result;
} catch (e) {
if (e instanceof ISYInitializationError) {
throw e;
} else {
throw new ISYInitializationError(e, 'loadNodes');
}
}
}
public async loadVariables(type: VariableType): Promise<any> {
try {
const that = this;
this.logger.info(`Loading ISY Variables of type: ${type}`);
let defs = await this.sendRequest(`vars/definitions/${type}`);
this.#createVariables(type, defs);
let vals = await this.sendRequest(`vars/get/${type}`);
this.#setVariableValues(vals);
} catch {
this.logger.warn('error loading variables of type: ' + VariableType[type]);
}
}
public nodeChangedHandler(node: ELKAlarmPanelDevice | ElkAlarmSensorDevice, 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);
// }
}
}
/*public override on(event: 'initializeCompleted', listener: () => void): this;
public override on(event: 'nodeAdded' | 'nodeRemoved' | 'nodeChanged', listener: (node?: ISYNode<any, any, any, any>) => void): this;
override on(event: 'initializeCompleted' | 'nodeAdded' | 'nodeRemoved' | 'nodeChanged', listener: (node?: ISYNode<any, any, any, any>) => void): this {
return super.on('nodeAdded',listener)
}*/
public async refreshStatuses() {
try {
this.logger.info('Refreshing ISY Node Statuses');
const that = this;
const result = await that.sendRequest('status');
if (that.isDebugEnabled) {
await writeDebugFile(result, 'ISYStatus.json', this.logger, this.storagePath);
}
//this.logger.debug(result);
for (const node of result.nodes.node) {
let device = that.getNode(node.id) as unknown as ISYNode<any, any, any, any>;
if (device !== null && device !== undefined) {
device.parseResult(node);
}
}
} catch (e) {
throw new Error(`Error refreshing statuses: ${JSON.stringify(e.message)}`);
}
}
public async sendGetVariable(id: any, type: any, handleResult: (arg0: number, arg1: number) => void) {
const uriToUse = `vars/get/${type}/${id}`;
return this.sendRequest(uriToUse).then((p) => handleResult(p.val, p.init));
}
public async sendISYCommand(path: string): Promise<any> {
// const uriToUse = `${this.protocol}://${this.address}/rest/${path}`;
this.logger.info(`Sending command...${path}`);
return this.sendRequest(path);
}
#formatParameter(name: string, value?: Utils.MaybeWithUOM<JsonPrimitive>): string {
if (value === undefined) return '';
if (Utils.hasUOM(value)) {
const [val, uom] = value;
if (val === undefined || val === null) return '';
return `${name}.uom${uom}=${this.#formatParamValue(val)}`;
}
return `${name}=${this.#formatParamValue(value)}`;
}
#formatDefaultParameter(value: Utils.MaybeWithUOM<JsonPrimitive>): string {
if (value === undefined) return '';
if (Utils.hasUOM(value)) {
const [val, uom] = value;
if (val === undefined || val === null) return '';
return `${this.#formatParamValue(val, uom)}/${uom}`;
}
return `${this.#formatParamValue(value)}`;
}
#formatParamValue(param: JsonPrimitive, uom?: UnitOfMeasure): string {
if (typeof param === 'string') return param;
if (typeof param === 'number') return param.toString();
if (typeof param === 'boolean') {
if (uom == UnitOfMeasure.OffOn) return param ? '100' : '0';
if (uom == UnitOfMeasure.Percent) return param ? '100' : '0';
if (uom == UnitOfMeasure.LevelFrom0To255) return param ? '255' : '0';
return param ? '1' : '0';
}
}
public async sendNodeCommand<P extends string | symbol, N extends ISYNode<any, any, any, any>>(node: N, command: string, defaultParameter?: Utils.MaybeWithUOM, parameters?: Record<P, Utils.MaybeWithUOM<JsonPrimitive> | undefined> | Utils.MaybeWithUOM<JsonPrimitive>): Promise<any> {
let uriToUse = `nodes/${node.address}/cmd/${command}?`;
if (defaultParameter !== null && defaultParameter !== undefined) {
if (Array.isArray(defaultParameter) || isPrimitive(defaultParameter)) {
uriToUse += `/${this.#formatDefaultParameter(defaultParameter)}?`;
}
}
if (parameters !== null && parameters !== undefined) {
if (typeof parameters === 'object' && !Array.isArray(parameters)) {
var q = parameters as Record<P, Utils.MaybeWithUOM<JsonPrimitive> | undefined>;
for (const paramName in q) {
let s = q[paramName];
if (paramName === 'value' || paramName === 'default') {
uriToUse += `/${this.#formatDefaultParameter(s)}?`;
continue;
} else {
let p = this.#formatParameter(paramName, s);
if (p === '') continue;
uriToUse += `${p}&`;
}
}
//uriToUse += `/${q[((p : Record<string,number|number>) => `${p[]}/${p.paramValue}` ).join('/')}`;
} else {
uriToUse += `/${this.#formatDefaultParameter(parameters)}`;
}
}
uriToUse =
uriToUse.endsWith('?') ? uriToUse.slice(0, -1)
: uriToUse.endsWith('&') ? uriToUse.slice(0, -1)
: uriToUse;
uriToUse = uriToUse.replace('?/', '/');
this.logger.info(`${node.name}: sending ${command} command: ${uriToUse}`);
return this.sendRequest(uriToUse, { requestLogLevel: 'info' });
}
public async sendRequest<T = any>(
url: string,
options: {
parserOptions?: ParserOptions | X2jOptions;
trailingSlash?: boolean;
requestLogLevel?: Utils.LogLevel;
responseLogLevel?: Utils.LogLevel;
errorLogLevel?: Utils.LogLevel;
throwOnError?: boolean;
} & Utils.ISYRequestConfig = { trailingSlash: true }
): Promise<T> {
const requestLogLevel = options.requestLogLevel ?? 'debug';
const responseLogLevel = options.responseLogLevel ?? 'silly';
url = `${url}${options.trailingSlash && !url.endsWith('/') ? '/' : ''}`;
const finalUrl = `${this.protocol}://${this.address}/rest/${url}`;
const reqOps = { ...this.axiosOptions, ...options };
this.logger.log(requestLogLevel, `Sending request to ${path.join('/rest', url)} with options: ${Utils.logStringify(reqOps, 0)}`);
/*{
auth: { username: this.credentials.username, password: this.credentials.password },
baseURL: `${this.protocol}://${this.address}`,
}*/
try {
delete reqOps.trailingSlash;
delete reqOps.requestLogLevel;
const response = await axios.get(reqOps.socketPath ? path.join('/rest', url) : finalUrl, reqOps);
if (response.data) {
if (response.headers['content-type'].toString().includes('xml')) {
let altParser = this.xmlParser;
if (options.parserOptions) {
if (typeof options.parserOptions === 'object') {
this.logger.info('Using custom parser options');
altParser = new XMLParser({ ...defaultXMLParserOptions, ...options.parserOptions });
}
}
// {curParser = new Parser({ ...defaultParserOptions, ...options.parserOptions });
//var altParser = new XMLParser({ ...defaultXMLParserOptions, ...options.parserOptions });
var s = altParser.parse(response.data);
this.logger.log(responseLogLevel ?? 'debug', `Response: ${JSON.stringify(s)}`);
return s;
} else if (response.headers['content-type'].toString().includes('json')) {
this.logger.log(responseLogLevel, `Response: ${JSON.stringify(response.data)}`);
return JSON.parse(response.data);
} else {
this.logger.log(responseLogLevel, `Response Header: ${JSON.stringify(response.headers)} Response: ${JSON.stringify(response.data)}`);
return response.data;
}
}
} catch (error) {
if (options.throwOnError) {
throw error;
} else {
if (options.errorLogLevel) {
this.logger.log(options.errorLogLevel, `Error sending request to ISY: ${error?.message}`);
} else {
this.logger.error(`Error sending request to ISY: ${error?.message}`);
}
}
}
}
public async sendSetVariable(id: any, type: any, value: any, handleResult: { (success: any): void; (arg0: boolean): void; (arg0: boolean): void }) {
const uriToUse = `/rest/vars/set/${type}/${id}/${value}`;
this.logger.info(`Sending ISY command...${uriToUse}`);
return this.sendRequest(uriToUse);
}
#variableChangedHandler(variable: { id: string; type: string }) {
this.logger.info(`Variable: ${variable.id} (${variable.type}) changed`);
}
public async close() {
try {
let p = new Promise<void>((resolve) =>
this.webSocket?.once('close', (x, y) => {
resolve();
})
);
if (this.guardianTimer) {
clearInterval(this.guardianTimer);
this.guardianTimer = null;
}
this.webSocket?.close();
await p;
} catch (e) {
this.logger.error(`Error closing websocket: ${e.message}`);
}
}
// #endregion Public Methods (24)
// #region Private Methods (11)
#checkForFailure(response: any): boolean {
return response === null || response instanceof Error || (response.RestResponse !== undefined && response.RestResponse.status !== 200);
}
#createVariableKey(type: VariableType, id: number) {
return `${type}:${id}`;
}
#createVariables(type: VariableType, result: any) {
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);
}
}
async #finishInitialize(success: boolean) {
if (!this.nodesLoaded) {
this.nodesLoaded = true;
//initializeCompleted();
if (success) {
// if (this.elkEnabled) {
// this.deviceList[this.elkAlarmPanel.address] = this.elkAlarmPanel;
// }
if (this.enableWebSocket) {
await this.initializeWebSocket();
//this.guardianTimer = setInterval(this.#guardian.bind(this), this.guardianInterval);
}
this.emit('initializeCompleted');
}
}
}
async #guardian() {
const timeNow = Date.now();
if (timeNow - this.lastActivity > this.guardianInterval) {
this.logger.warn(`Guardian: Detected no activity in more then ${this.guardianInterval} seconds. Reinitializing web sockets`);
await this.refreshStatuses();
await this.initializeWebSocket();
this.guardianTimer.refresh();
this.logger.warn('Guardian: WebSocket reinitialized');
this.lastActivity = Date.now();
}
}
async #readDeviceNodes(obj: { nodes: { node: NodeInfo[] } }) {
try {
this.logger.info('Reading Device Nodes');
for (const nodeInfo of obj.nodes.node) {
try {
this.logger.debug(`Loading Device Node: ${JSON.stringify(nodeInfo)}`);
if (nodeInfo.enabled) {
let newDevice = await DeviceFactory.create(this, nodeInfo);
if (!newDevice && nodeInfo.pnode == nodeInfo.address) {
this.logger.warn(`Device type resolution failed for ${nodeInfo.name} with type: ${nodeInfo.type} and nodedef: ${nodeInfo.nodeDefId}`);
newDevice = new GenericNode(this, nodeInfo);
}
if (newDevice) {
if (this.devices.set) this.devices.set(newDevice.address, newDevice);
}
/* if (!this.deviceMap.has(nodeInfo.pnode)) {
const address = nodeInfo.address;
this.deviceMap[nodeInfo.pnode] = {
address
};
} else {
this.deviceMap[nodeInfo.pnode].push(nodeInfo.address);
}
let newDevice = null;
// let deviceTypeInfo = this.isyTypeToTypeName(device.type, device.address);
// this.logger.info(JSON.stringify(deviceTypeInfo));
const enabled = nodeInfo.enabled ?? true;
const d = await NodeFactory.get(nodeInfo);
const m = DeviceFactory.getDeviceDetails(nodeInfo);
const cls = m.class ?? d;
nodeInfo.property = Array.isArray(nodeInfo.property) ? nodeInfo.property : [nodeInfo.property];
nodeInfo.state = nodeInfo.property.reduce((acc, p) => {
if (p && p?.id) {
p.name = p.name == '' ? undefined : p.name;
acc[p.id] = p;
}
return acc;
}, {});
if (cls) {
try {
newDevice = new cls(this, nodeInfo);
} catch (e) {
this.logger.error(`Error creating device ${nodeInfo.name} with type ${nodeInfo.type} and nodedef ${nodeInfo.nodeDefId}: ${e.message}`);
continue;
}
//newDevice = new cls(this, nodeInfo) as ISYDeviceNode<any, any, any, any>;
}
if (m && newDevice) {
newDevice.productName = m.name;
newDevice.model = `(${m.modelNumber}) ${m.name} v.${m.version}`;
newDevice.modelNumber = m.modelNumber;
newDevice.version = m.version;
}
if (enabled) {
if (newDevice === null) {
this.logger.warn(`Device type resolution failed for ${nodeInfo.name} with type: ${nodeInfo.type} and nodedef: ${nodeInfo.nodeDefId}`);
newDevice = new GenericNode(this, nodeInfo);
} else if (newDevice !== null) {
if (m.unsupported) {
this.logger.warn('Device not currently supported: ' + JSON.stringify(nodeInfo) + ' /n It has been mapped to: ' + d.name);
}
try {
//await newDevice.refreshNotes();
} catch (e) {
this.logger.debug('No notes found.');
}
// if (!newDevice.hidden) {
// }
// this.deviceList.push(newDevice);
} else {
}
this.nodeMap.set(newDevice.address, CompositeDevice.isComposite(newDevice) ? newDevice.root : newDevice);
if (CompositeDevice.isComposite(newDevice)) {
if (this.deviceList.set) this.deviceList.set(newDevice.address, newDevice);
} */
} else {
this.logger.info(`Ignoring disabled device: ${nodeInfo.name}`);
}
} catch (e) {
this.logger.error(`Error loading device node: ${e.message}`);
}
}
await DeviceFactory.addChildNodes(this);
this.logger.info(`${this.nodeMap.size} nodes added.`);
for (const node of this.nodeMap.values()) {
try {
//await node.refreshNotes();
} catch (e) {
this.logger.silly('No notes found.');
}
}
/*for (const node of this.nodeMap.values()) {
if (node.parentAddress !== node.address) {
let parent = this.deviceList.get(node.parentAddress) as unknown as CompositeDevice<any, any>;
if (parent && CompositeDevice.isComposite(parent)) parent.addNode(node);
} else if (!this.deviceList.has(node.address)) {
this.deviceList.set(node.address, node as unknown as ISYDevice<any, any, any, any>);
}
}*/
this.logger.info(`${this.devices.size} unique devices added.`);
} catch (e) {
throw new ISYInitializationError(e, 'readDevices');
}
}
async #readFolderNodes(result: { nodes: { folder: any } }) {
try {
this.logger.info('Reading Folder Nodes');
if (result?.nodes?.folder) {
for (const folder of result.nodes.folder) {
this.logger.debug(`Loading Folder Node: ${JSON.stringify(folder)}`);
this.folderMap.set(folder.address, folder.name);
}
}
} catch (e) {
throw new ISYInitializationError(e, 'readFolders');
}
}
async #readSceneNodes(result: { nodes: { group: any } }) {
try {
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);
}
this.logger.info(`${this.sceneList.size} scenes added.`);
} catch (e) {
throw new ISYInitializationError(e, 'readScenes');
}
}
#setVariableValues(result: any) {
for (const vals of result.vars.var) {
const type = Number(vals.type) as VariableType;
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);
}
}
}
// #endregion Private Methods (11)
}
export { CompositeDevice } from './Devices/CompositeDevice.js';
export { ISYDeviceNode as DeviceNode } from './Devices/ISYDeviceNode.js';
export { ISYNode as Node } from './ISYNode.js';
export * from './Converters.js';
export * from './Definitions/index.js';
export * as Devices from './Devices/index.js';
export * from './ISYError.js';
export { ISYError } from './ISYError.js';
export { ISYVariable as Variable } from './ISYVariable.js';
export * from './Model/index.js';
export { ISYScene as Scene } from './Scenes/ISYScene.js';
export * from './Utils.js';
export { VariableType } from './VariableType.js';
export namespace ISY {
export interface Config extends ISYConfig {}
export type Device<F extends Family, D, C, E> = ISYDevice<F, D, C, E>;
}