matterbridge-zigbee2mqtt
Version:
Matterbridge zigbee2mqtt plugin
832 lines (831 loc) • 38.8 kB
JavaScript
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { EventEmitter } from 'node:events';
import { connectAsync } from 'mqtt';
import { AnsiLogger, rs, db, dn, gn, er, zb, hk, id, idn, ign, REVERSE, REVERSEOFF } from 'node-ansi-logger';
export class Zigbee2MQTT extends EventEmitter {
log;
mqttHost;
mqttPort;
mqttTopic;
mqttUsername;
mqttPassword;
mqttClient;
mqttIsConnected = false;
mqttIsReconnecting = false;
mqttIsEnding = false;
mqttDataPath = '';
mqttPublishQueue = [];
mqttPublishQueueTimeout = undefined;
mqttPublishInflights = 0;
mqttKeepaliveInterval = undefined;
z2mIsAvailabilityEnabled;
z2mIsOnline;
z2mPermitJoin;
z2mPermitJoinTimeout;
z2mVersion;
z2mBridge;
z2mDevices;
z2mGroups;
loggedBridgePayloads = 0;
loggedPublishPayloads = 0;
options = {
clientId: 'matterbridge_' + crypto.randomBytes(8).toString('hex'),
keepalive: 60,
protocolVersion: 5,
reconnectPeriod: 5000,
connectTimeout: 60 * 1000,
username: undefined,
password: undefined,
clean: true,
};
constructor(mqttHost, mqttPort, mqttTopic, mqttUsername, mqttPassword, mqttClientId, protocolVersion = 5, ca, rejectUnauthorized, cert, key, debug = false) {
super();
this.log = new AnsiLogger({ logName: 'Zigbee2MQTT', logTimestampFormat: 4, logLevel: debug ? "debug" : "info" });
this.mqttHost = mqttHost;
this.mqttPort = mqttPort;
this.mqttTopic = mqttTopic;
this.mqttUsername = mqttUsername;
this.mqttPassword = mqttPassword;
this.options.username = mqttUsername !== undefined && mqttUsername !== '' ? mqttUsername : undefined;
this.options.password = mqttPassword !== undefined && mqttPassword !== '' ? mqttPassword : undefined;
if (mqttClientId)
this.options.clientId = mqttClientId;
this.options.protocolVersion = protocolVersion;
if (mqttHost.startsWith('mqtts://') || mqttHost.startsWith('wss://')) {
this.log.debug('Using mqtts:// protocol for secure MQTT connection');
if (!ca) {
this.log.info('When using mqtts:// protocol, you must provide the ca certificate for SSL/TLS connections with self-signed certificates.');
}
else {
try {
fs.accessSync(ca, fs.constants.R_OK);
this.options.ca = fs.readFileSync(ca);
this.log.info(`Successfully read the CA certificate from ${ca}`);
}
catch (error) {
this.log.error(`Error reading the CA certificate from ${ca}:`, error);
}
}
this.options.rejectUnauthorized = rejectUnauthorized !== undefined ? rejectUnauthorized : true;
this.log.info(`TLS rejectUnauthorized is set to ${this.options.rejectUnauthorized}`);
if (cert && key) {
try {
fs.accessSync(cert, fs.constants.R_OK);
this.options.cert = fs.readFileSync(cert);
this.log.info(`Successfully read the client certificate from ${cert}`);
}
catch (error) {
this.log.error(`Error reading the client certificate from ${cert}:`, error);
}
try {
fs.accessSync(key, fs.constants.R_OK);
this.options.key = fs.readFileSync(key);
this.log.info(`Successfully read the client key from ${key}`);
}
catch (error) {
this.log.error(`Error reading the client key from ${key}:`, error);
}
}
}
else if (mqttHost.startsWith('mqtt://') || mqttHost.startsWith('ws://')) {
this.log.debug('Using mqtt:// protocol for non-secure MQTT connection');
if (ca) {
this.log.warn('You are using mqtt:// protocol, but you provided a CA certificate. It will be ignored.');
}
if (cert) {
this.log.warn('You are using mqtt:// protocol, but you provided a certificate. It will be ignored.');
}
if (key) {
this.log.warn('You are using mqtt:// protocol, but you provided a key. It will be ignored.');
}
}
else if (mqttHost.startsWith('mqtt+unix://')) {
this.log.debug('Using mqtt+unix:// protocol for MQTT connection over Unix socket');
if (ca) {
this.log.warn('You are using mqtt+unix:// protocol, but you provided a CA certificate. It will be ignored.');
}
if (cert) {
this.log.warn('You are using mqtt+unix:// protocol, but you provided a certificate. It will be ignored.');
}
if (key) {
this.log.warn('You are using mqtt+unix:// protocol, but you provided a key. It will be ignored.');
}
}
else {
this.log.warn('You are using an unsupported MQTT protocol. Please use mqtt:// or mqtts:// or ws:// or wss:// or mqtt+unix://.');
}
this.z2mIsAvailabilityEnabled = false;
this.z2mIsOnline = false;
this.z2mPermitJoin = false;
this.z2mPermitJoinTimeout = 0;
this.z2mVersion = '';
this.z2mBridge = {};
this.z2mDevices = [];
this.z2mGroups = [];
this.log.debug(`Created new instance with host: ${mqttHost} port: ${mqttPort} protocol ${protocolVersion} topic: ${mqttTopic} username: ${mqttUsername !== undefined && mqttUsername !== '' ? mqttUsername : 'undefined'} password: ${mqttPassword !== undefined && mqttPassword !== '' ? '*****' : 'undefined'}`);
}
setLogDebug(logDebug) {
this.log.logLevel = logDebug ? "debug" : "info";
}
setLogLevel(logLevel) {
this.log.logLevel = logLevel;
}
async setDataPath(dataPath) {
try {
await fs.promises.mkdir(dataPath, { recursive: true });
this.mqttDataPath = dataPath;
this.log.debug(`Data directory ${this.mqttDataPath} created successfully.`);
}
catch (e) {
const error = e;
if (error.code === 'EEXIST') {
this.log.debug('Data directory already exists');
}
else {
this.log.error('Error creating data directory:', error);
}
}
try {
const filePath = path.join(this.mqttDataPath, 'bridge-payloads.txt');
fs.unlinkSync(filePath);
}
catch (error) {
this.log.debug(`Error deleting bridge-payloads.txt: ${error}`);
}
try {
const filePath = path.join(this.mqttDataPath, 'bridge-publish-payloads.txt');
fs.unlinkSync(filePath);
}
catch (error) {
this.log.debug(`Error deleting bridge-publish-payloads.txt: ${error}`);
}
try {
const filePath = path.join(this.mqttDataPath, 'matter-commands.txt');
fs.unlinkSync(filePath);
}
catch (error) {
this.log.debug(`Error deleting matter-commands.txt: ${error}`);
}
}
getUrl() {
return this.mqttHost.includes('unix://') ? this.mqttHost : this.mqttHost + ':' + this.mqttPort.toString();
}
async start() {
this.log.debug(`Starting connection to ${this.getUrl()}...`);
connectAsync(this.getUrl(), this.options)
.then((client) => {
this.log.debug('Connection established');
this.mqttClient = client;
this.mqttClient.on('connect', (packet) => {
this.log.debug(`MQTT client connect to ${this.getUrl()}${rs}`);
this.mqttIsConnected = true;
this.mqttIsReconnecting = false;
this.mqttIsEnding = false;
this.emit('mqtt_connect');
});
this.mqttClient.on('reconnect', () => {
this.log.debug(`MQTT client reconnect to ${this.getUrl()}${rs}`);
this.mqttIsReconnecting = true;
this.emit('mqtt_reconnect');
});
this.mqttClient.on('disconnect', (packet) => {
this.log.debug('MQTT client diconnect', this.getUrl(), packet);
this.emit('mqtt_disconnect');
});
this.mqttClient.on('close', () => {
this.log.debug('MQTT client close');
this.mqttIsConnected = false;
this.mqttIsReconnecting = false;
this.emit('mqtt_close');
});
this.mqttClient.on('end', () => {
this.log.debug('MQTT client end');
this.mqttIsConnected = false;
this.mqttIsReconnecting = false;
this.emit('mqtt_end');
});
this.mqttClient.on('offline', () => {
this.log.debug('MQTT client offline');
this.emit('mqtt_offline');
});
this.mqttClient.on('error', (error) => {
this.log.debug('MQTT client error', error);
this.emit('mqtt_error', error);
});
this.mqttClient.on('packetsend', (packet) => {
});
this.mqttClient.on('packetreceive', (packet) => {
});
this.mqttClient.on('message', (topic, payload, packet) => {
this.messageHandler(topic, payload);
});
this.log.debug('Started');
this.mqttIsConnected = true;
this.mqttIsReconnecting = false;
this.mqttIsEnding = false;
this.emit('mqtt_connect');
this.mqttKeepaliveInterval = setInterval(async () => {
this.log.debug('Publishing keepalive MQTT message');
try {
await this.mqttClient?.publishAsync(`clients/${this.options.clientId}/heartbeat`, 'alive', { qos: 2 });
}
catch (error) {
this.log.error('Error publishing keepalive MQTT message:', error);
}
}, (this.options.keepalive ?? 60) * 1000).unref();
return;
})
.catch((error) => {
this.log.error(`Error connecting to ${this.getUrl()}: ${error.message}`);
this.emit('mqtt_error', error);
});
}
async stop() {
if (this.mqttKeepaliveInterval) {
clearInterval(this.mqttKeepaliveInterval);
this.mqttKeepaliveInterval = undefined;
}
if (!this.mqttClient || this.mqttIsEnding) {
this.log.debug('Already stopped!');
}
else {
this.mqttIsEnding = true;
this.log.debug('Ending connection...');
this.mqttClient
.endAsync(false)
.then(() => {
this.mqttClient?.removeAllListeners();
this.mqttIsConnected = false;
this.mqttIsReconnecting = false;
this.mqttIsEnding = false;
this.mqttClient = undefined;
this.log.debug('Connection closed');
return;
})
.catch((error) => {
this.log.error(`Error closing connection: ${error.message}`);
});
}
}
async subscribe(topic) {
if (this.mqttClient && this.mqttIsConnected) {
this.log.debug(`Subscribing topic: ${topic}`);
this.mqttClient
.subscribeAsync(topic, { qos: 2 })
.then(() => {
this.log.debug(`Subscribe success on topic: ${topic}`);
this.emit('mqtt_subscribed');
return;
})
.catch((error) => {
this.log.error(`Subscribe error: ${error} on topic: ${topic}`);
});
}
else {
this.log.error('Unable to subscribe, client not connected or unavailable');
}
}
async publish(topic, message, queue = false) {
const startInterval = () => {
if (this.mqttPublishQueueTimeout) {
return;
}
this.log.debug(`**Start publish ${REVERSE}[${this.mqttPublishQueue.length}-${this.mqttPublishInflights}]${REVERSEOFF} interval`);
this.mqttPublishQueueTimeout = setInterval(async () => {
if (this.mqttClient && this.mqttPublishQueue.length > 0) {
this.log.debug(`**Publish ${REVERSE}[${this.mqttPublishQueue.length}-${this.mqttPublishInflights}]${REVERSEOFF} topic: ${this.mqttPublishQueue[0].topic} message: ${this.mqttPublishQueue[0].message}${rs}`);
try {
this.mqttPublishInflights++;
await this.mqttClient.publishAsync(this.mqttPublishQueue[0].topic, this.mqttPublishQueue[0].message, { qos: 2 });
this.log.debug(`**Publish ${REVERSE}[${this.mqttPublishQueue.length}-${this.mqttPublishInflights}]${REVERSEOFF} success on topic: ${topic} message: ${message} inflights: ${this.mqttPublishInflights}`);
this.emit('mqtt_published');
this.mqttPublishInflights--;
}
catch (error) {
this.mqttPublishInflights--;
this.log.error(`****Publish ${REVERSE}[${this.mqttPublishQueue.length}-${this.mqttPublishInflights}]${REVERSEOFF} error: ${error} on topic: ${topic} message: ${message} inflights: ${this.mqttPublishInflights}`);
}
this.mqttPublishQueue.splice(0, 1);
}
else {
stopInterval();
}
}, 50);
};
const stopInterval = () => {
if (this.mqttPublishQueueTimeout) {
this.log.debug(`**Stop publish ${REVERSE}[${this.mqttPublishQueue.length}-${this.mqttPublishInflights}]${REVERSEOFF} interval`);
clearInterval(this.mqttPublishQueueTimeout);
this.mqttPublishQueueTimeout = undefined;
}
};
if (this.mqttClient && this.mqttIsConnected) {
if (queue) {
startInterval();
this.mqttPublishQueue.push({ topic: topic, message: message });
this.log.debug(`**Add to publish ${REVERSE}[${this.mqttPublishQueue.length}-${this.mqttPublishInflights}]${REVERSEOFF} topic: ${topic} message: ${message}${rs}`);
return;
}
this.log.debug(`Publishing ${REVERSE}[${this.mqttPublishInflights}]${REVERSEOFF} topic: ${topic} message: ${message}`);
try {
this.mqttPublishInflights++;
await this.mqttClient.publishAsync(topic, message, { qos: 2 });
this.log.debug(`Publish ${REVERSE}[${this.mqttPublishInflights}]${REVERSEOFF} success on topic: ${topic} message: ${message}`);
this.emit('mqtt_published');
this.mqttPublishInflights--;
if (this.log.logLevel === "debug" && this.loggedPublishPayloads < 10000) {
const filePath = path.join(this.mqttDataPath, 'bridge-publish-payloads.txt');
fs.appendFileSync(filePath, `${new Date().toLocaleString()} - ` + JSON.stringify({ topic, message }).replaceAll('\\"', '"') + '\n');
this.loggedPublishPayloads++;
}
}
catch (error) {
this.mqttPublishInflights--;
this.log.error(`****Publish ${REVERSE}[${this.mqttPublishInflights}]${REVERSEOFF} error: ${error} on topic: ${topic} message: ${message}`);
}
}
else {
this.log.error('Unable to publish, client not connected or unavailable.');
}
}
async writeBufferJSON(file, buffer) {
const filePath = path.join(this.mqttDataPath, file);
let jsonData;
try {
jsonData = this.tryJsonParse(buffer.toString());
}
catch (error) {
this.log.error('writeBufferJSON: parsing error:', error);
return;
}
fs.promises
.writeFile(`${filePath}.json`, JSON.stringify(jsonData, null, 2))
.then(() => {
this.log.debug(`Successfully wrote to ${filePath}.json`);
return;
})
.catch((error) => {
this.log.error(`Error writing to ${filePath}.json:`, error);
});
}
async writeFile(file, data) {
const filePath = path.join(this.mqttDataPath, file);
fs.promises
.writeFile(`${filePath}`, data)
.then(() => {
this.log.debug(`Successfully wrote to ${filePath}`);
return;
})
.catch((error) => {
this.log.error(`Error writing to ${filePath}:`, error);
});
}
tryJsonParse(text) {
try {
return JSON.parse(text);
}
catch (error) {
this.log.debug(`tryJsonParse: parsing error from ${text}`);
this.log.error('tryJsonParse: parsing error:', error);
return {};
}
}
messageHandler(topic, payload) {
if (topic.startsWith(this.mqttTopic + '/bridge/state')) {
const payloadString = payload.toString();
let data = {};
if (payloadString.startsWith('{') && payloadString.endsWith('}')) {
data = this.tryJsonParse(payload.toString());
}
else {
data = { state: payloadString };
}
if (data.state === 'online') {
this.z2mIsOnline = true;
this.emit('online');
}
else if (data.state === 'offline') {
this.z2mIsOnline = false;
this.emit('offline');
}
this.log.debug(`Message bridge/state online => ${this.z2mIsOnline}`);
}
else if (topic.startsWith(this.mqttTopic + '/bridge/info')) {
this.z2mBridge = this.tryJsonParse(payload.toString());
this.z2mPermitJoin = this.z2mBridge.permit_join;
this.z2mPermitJoinTimeout = this.z2mBridge.permit_join_timeout;
this.z2mVersion = this.z2mBridge.version;
this.z2mIsAvailabilityEnabled = this.z2mBridge.config.availability !== undefined;
this.log.debug(`Message bridge/info availability => ${this.z2mIsAvailabilityEnabled}`);
this.log.debug(`Message bridge/info version => ${this.z2mVersion}`);
this.log.debug(`Message bridge/info permit_join => ${this.z2mPermitJoin} timeout => ${this.z2mPermitJoinTimeout}`);
this.log.debug(`Message bridge/info advanced.output => ${this.z2mBridge.config.advanced.output}`);
this.log.debug(`Message bridge/info advanced.legacy_api => ${this.z2mBridge.config.advanced.legacy_api}`);
this.log.debug(`Message bridge/info advanced.legacy_availability_payload => ${this.z2mBridge.config.advanced.legacy_availability_payload}`);
if (this.z2mBridge.config.advanced.output === 'attribute')
this.log.error(`Message bridge/info advanced.output must be 'json' or 'attribute_and_json'. Now is ${this.z2mBridge.config.advanced.output}`);
if (this.z2mBridge.config.advanced.legacy_api === true)
this.log.info(`Message bridge/info advanced.legacy_api is ${this.z2mBridge.config.advanced.legacy_api}`);
if (this.z2mBridge.config.advanced.legacy_availability_payload === true)
this.log.info(`Message bridge/info advanced.legacy_availability_payload is ${this.z2mBridge.config.advanced.legacy_availability_payload}`);
this.emit('bridge-info', this.z2mBridge);
if (this.log.logLevel === "debug")
this.writeBufferJSON('bridge-info', payload);
}
else if (topic.startsWith(this.mqttTopic + '/bridge/devices')) {
if (this.log.logLevel === "debug")
this.writeBufferJSON('bridge-devices', payload);
this.z2mDevices = this.tryJsonParse(payload.toString());
this.emit('bridge-devices', this.z2mDevices);
}
else if (topic.startsWith(this.mqttTopic + '/bridge/groups')) {
if (this.log.logLevel === "debug")
this.writeBufferJSON('bridge-groups', payload);
this.z2mGroups = this.tryJsonParse(payload.toString());
this.emit('bridge-groups', this.z2mGroups);
}
else if (topic.startsWith(this.mqttTopic + '/bridge/extensions')) {
const extensions = this.tryJsonParse(payload.toString());
for (const extension of extensions) {
this.log.debug(`Message topic: ${topic} extension: ${extension.name}`);
}
}
else if (topic.startsWith(this.mqttTopic + '/bridge/event')) {
this.handleEvent(payload);
}
else if (topic.startsWith(this.mqttTopic + '/bridge/request')) {
const data = this.tryJsonParse(payload.toString());
this.log.info(`Message topic: ${topic} payload:${rs}`, data);
}
else if (topic.startsWith(this.mqttTopic + '/bridge/response')) {
if (topic.startsWith(this.mqttTopic + '/bridge/response/networkmap')) {
this.handleResponseNetworkmap(payload);
return;
}
if (topic.startsWith(this.mqttTopic + '/bridge/response/permit_join')) {
this.handleResponsePermitJoin(payload);
return;
}
if (topic.startsWith(this.mqttTopic + '/bridge/response/device/rename')) {
this.handleResponseDeviceRename(payload);
return;
}
if (topic.startsWith(this.mqttTopic + '/bridge/response/device/remove')) {
this.handleResponseDeviceRemove(payload);
return;
}
if (topic.startsWith(this.mqttTopic + '/bridge/response/device/options')) {
this.handleResponseDeviceOptions(payload);
return;
}
if (topic.startsWith(this.mqttTopic + '/bridge/response/group/add')) {
this.handleResponseGroupAdd(payload);
return;
}
if (topic.startsWith(this.mqttTopic + '/bridge/response/group/remove')) {
this.handleResponseGroupRemove(payload);
return;
}
if (topic.startsWith(this.mqttTopic + '/bridge/response/group/rename')) {
this.handleResponseGroupRename(payload);
return;
}
if (topic.startsWith(this.mqttTopic + '/bridge/response/group/members/add')) {
this.handleResponseGroupAddMember(payload);
return;
}
if (topic.startsWith(this.mqttTopic + '/bridge/response/group/members/remove')) {
this.handleResponseGroupRemoveMember(payload);
return;
}
const data = this.tryJsonParse(payload.toString());
this.log.debug(`Message topic: ${topic} payload:${rs}`, data);
}
else if (topic.startsWith(this.mqttTopic + '/bridge/logging')) {
}
else if (topic.startsWith(this.mqttTopic + '/bridge/config')) {
this.log.debug(`Message topic: ${topic}`);
}
else if (topic.startsWith(this.mqttTopic + '/bridge/definitions')) {
this.log.debug(`Message topic: ${topic}`);
}
else if (topic.startsWith(this.mqttTopic + '/bridge')) {
this.log.debug(`Message topic: ${topic}`);
}
else {
let entity = topic.replace(this.mqttTopic + '/', '');
let service = '';
if (entity.search('/')) {
const parts = entity.split('/');
entity = parts[0];
service = parts[1];
}
if (entity === 'Coordinator') {
const data = this.tryJsonParse(payload.toString());
if (service === 'availability') {
if (data.state === 'online') {
this.log.debug(`Received ONLINE for ${id}Coordinator${rs}`, data);
}
else if (data.state === 'offline') {
this.log.debug(`Received OFFLINE for ${id}Coordinator${rs}`, data);
}
}
return;
}
if (this.log.logLevel === "debug" && this.loggedBridgePayloads < 10000) {
const logEntry = {
entity,
service,
payload: payload.toString(),
};
const filePath = path.join(this.mqttDataPath, 'bridge-payloads.txt');
fs.appendFileSync(filePath, `${new Date().toLocaleString()} - ` + JSON.stringify(logEntry).replaceAll('\\"', '"') + '\n');
this.loggedBridgePayloads++;
}
const foundDevice = this.z2mDevices.find((device) => device.ieee_address === entity || device.friendly_name === entity);
if (foundDevice) {
this.handleDeviceMessage(foundDevice, entity, service, payload);
}
else {
const foundGroup = this.z2mGroups.find((group) => group.friendly_name === entity);
if (foundGroup) {
this.handleGroupMessage(foundGroup, entity, service, payload);
}
else {
this.log.debug('Message for ***unknown*** entity:', entity, 'service:', service, 'payload:', payload);
}
}
}
}
handleDeviceMessage(device, entity, service, payload) {
if (payload.length === 0 || payload === null) {
return;
}
const payloadString = payload.toString();
let data = {};
if (payloadString.startsWith('{') && payloadString.endsWith('}')) {
data = this.tryJsonParse(payload.toString());
}
else {
data = { state: payloadString };
}
if (service === 'availability') {
if (data.state === 'online') {
this.emit('availability', entity, true);
this.emit('ONLINE-' + entity);
}
else if (data.state === 'offline') {
this.emit('availability', entity, false);
this.emit('OFFLINE-' + entity);
}
}
else if (service === 'get') {
}
else if (service === 'set') {
}
else if (service === undefined) {
this.emit('message', entity, data);
this.emit('MESSAGE-' + entity, data);
}
else {
}
}
handleGroupMessage(group, entity, service, payload) {
if (payload.length === 0 || payload === null) {
return;
}
const payloadString = payload.toString();
let data = {};
if (payloadString.startsWith('{') && payloadString.endsWith('}')) {
data = this.tryJsonParse(payload.toString());
}
else {
data = { state: payloadString };
}
data['last_seen'] = new Date().toISOString();
if (service === 'availability') {
if (data.state === 'online') {
this.emit('availability', entity, true);
this.emit('ONLINE-' + entity);
}
else if (data.state === 'offline') {
this.emit('availability', entity, false);
this.emit('OFFLINE-' + entity);
}
}
else if (service === 'get') {
}
else if (service === 'set') {
}
else if (service === undefined) {
this.emit('MESSAGE-' + entity, data);
}
else {
}
}
handleResponseNetworkmap(payload) {
const data = this.tryJsonParse(payload.toString());
const topology = data.data.value;
const lqi = (lqi) => {
if (lqi < 50) {
return `\x1b[31m${lqi.toString().padStart(3, ' ')}${db}`;
}
else if (lqi > 200) {
return `\x1b[32m${lqi.toString().padStart(3, ' ')}${db}`;
}
else {
return `\x1b[38;5;251m${lqi.toString().padStart(3, ' ')}${db}`;
}
};
const depth = (depth) => {
if (depth === 255) {
return `\x1b[32m${depth.toString().padStart(3, ' ')}${db}`;
}
else {
return `\x1b[38;5;251m${depth.toString().padStart(3, ' ')}${db}`;
}
};
const relationship = (relationship) => {
if (relationship === 0) {
return `${zb}${relationship}-IsParent ${db}`;
}
else if (relationship === 1) {
return `${hk}${relationship}-IsAChild ${db}`;
}
else {
return `${relationship}-IsASibling`;
}
};
const friendlyName = (ieeeAddr) => {
const node = topology.nodes.find((node) => node.ieeeAddr === ieeeAddr);
if (node) {
if (node.type === 'Coordinator') {
return `\x1b[48;5;1m\x1b[38;5;255m${node.friendlyName} [C]${rs}${db}`;
}
else if (node.type === 'Router') {
return `${dn}${node.friendlyName} [R]${db}`;
}
else if (node.type === 'EndDevice') {
return `${gn}${node.friendlyName} [E]${db}`;
}
}
return `${er}${ieeeAddr}${db}`;
};
const timePassedSince = (lastSeen) => {
const now = Date.now();
const difference = now - lastSeen;
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
if (days > 0) {
return `${days} days ago`;
}
const hours = Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
if (hours > 0) {
return `${hours} hours ago`;
}
const minutes = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60));
if (minutes > 0) {
return `${minutes} minutes ago`;
}
const seconds = Math.floor((difference % (1000 * 60)) / 1000);
return `${seconds} seconds ago`;
};
if (this.log.logLevel === "debug")
this.writeBufferJSON('networkmap_' + data.data.type, payload);
if (data.data.type === 'graphviz') {
if (this.log.logLevel === "debug")
this.writeFile('networkmap_' + data.data.type + '.txt', data.data.value);
}
if (data.data.type === 'plantuml') {
if (this.log.logLevel === "debug")
this.writeFile('networkmap_' + data.data.type + '.txt', data.data.value);
}
if (data.data.type === 'raw') {
this.log.warn('Network map nodes:');
topology.nodes.sort((a, b) => a.friendlyName.localeCompare(b.friendlyName));
topology.nodes.forEach((node, index) => {
this.log.debug(`Node [${index.toString().padStart(3, ' ')}] ${node.type === 'EndDevice' ? ign : node.type === 'Router' ? idn : '\x1b[48;5;1m\x1b[38;5;255m'}${node.friendlyName}${rs}${db} addr: ${node.ieeeAddr}-0x${node.networkAddress.toString(16)} type: ${node.type} lastseen: ${timePassedSince(node.lastSeen)}`);
const sourceLinks = topology.links.filter((link) => link.sourceIeeeAddr === node.ieeeAddr);
sourceLinks.sort((a, b) => a.lqi - b.lqi);
sourceLinks.forEach((link, index) => {
this.log.debug(` link [${index.toString().padStart(4, ' ')}] lqi: ${lqi(link.lqi)} depth: ${depth(link.depth)} relation: ${relationship(link.relationship)} > > > ${friendlyName(link.target.ieeeAddr)}`);
});
const targetLinks = topology.links.filter((link) => link.targetIeeeAddr === node.ieeeAddr);
targetLinks.sort((a, b) => a.lqi - b.lqi);
targetLinks.forEach((link, index) => {
this.log.debug(` link [${index.toString().padStart(4, ' ')}] lqi: ${lqi(link.lqi)} depth: ${depth(link.depth)} relation: ${relationship(link.relationship)} < < < ${friendlyName(link.source.ieeeAddr)}`);
});
});
}
}
handleResponseDeviceRename(payload) {
const json = this.tryJsonParse(payload.toString());
this.log.debug(`handleResponseDeviceRename from ${json.data.from} to ${json.data.to} status ${json.status}`);
const device = this.z2mDevices.find((device) => device.friendly_name === json.data.to);
this.emit('device_rename', device?.ieee_address, json.data.from, json.data.to);
}
handleResponseDeviceRemove(payload) {
const json = this.tryJsonParse(payload.toString());
this.log.debug(`handleResponseDeviceRemove name ${json.data.id} status ${json.status} block ${json.data.block} force ${json.data.force}`);
this.emit('device_remove', json.data.id, json.status, json.data.block, json.data.force);
}
handleResponseDeviceOptions(payload) {
const json = this.tryJsonParse(payload.toString());
this.log.debug(`handleResponseDeviceOptions ieee_address ${json.data.id} status ${json.status} from ${json.data.from} to ${json.data.to}`);
this.emit('device_options', json.data.id, json.status, json.data.from, json.data.to);
}
handleResponseGroupAdd(payload) {
const json = this.tryJsonParse(payload.toString());
this.log.debug(`handleResponseGroupAdd() friendly_name ${json.data.friendly_name} id ${json.data.id} status ${json.status}`);
if (json.status === 'ok') {
this.emit('group_add', json.data.friendly_name, json.data.id, json.status);
}
}
handleResponseGroupRemove(payload) {
const json = this.tryJsonParse(payload.toString());
this.log.debug(`handleResponseGroupRemove() friendly_name ${json.data.id} status ${json.status}`);
if (json.status === 'ok') {
this.emit('group_remove', json.data.id, json.status);
}
}
handleResponseGroupRename(payload) {
const json = this.tryJsonParse(payload.toString());
this.log.debug(`handleResponseGroupRename() from ${json.data.from} to ${json.data.to} status ${json.status}`);
if (json.status === 'ok') {
this.emit('group_rename', json.data.from, json.data.to, json.status);
}
}
handleResponseGroupAddMember(payload) {
const json = this.tryJsonParse(payload.toString());
this.log.debug(`handleResponseGroupAddMembers() add to group friendly_name ${json.data.group} device ieee_address ${json.data.device} status ${json.status}`);
if (json.status === 'ok' && json.data.device && json.data.device.includes('/')) {
this.emit('group_add_member', json.data.group, json.data.device.split('/')[0], json.status);
}
}
handleResponseGroupRemoveMember(payload) {
const json = this.tryJsonParse(payload.toString());
this.log.debug(`handleResponseGroupRemoveMember() remove from group friendly_name ${json.data.group} device friendly_name ${json.data.device} status ${json.status}`);
if (json.status === 'ok') {
this.emit('group_remove_member', json.data.group, json.data.device, json.status);
}
}
handleResponsePermitJoin(payload) {
const json = this.tryJsonParse(payload.toString());
this.log.debug(`handleResponsePermitJoin() device: ${json.data.device ? json.data.device : 'All'} time: ${json.data.time} value: ${json.data.value} status: ${json.status}`);
if (json.status === 'ok') {
this.emit('permit_join', json.data.device, json.data.time, json.data.value);
}
}
handleEvent(payload) {
const json = this.tryJsonParse(payload.toString());
switch (json.type) {
case undefined:
this.log.error('handleEvent() undefined type', json);
break;
case 'device_leave':
this.log.debug(`handleEvent() type: device_leave name: ${json.data.friendly_name} address: ${json.data.ieee_address}`);
this.emit('device_leave', json.data.friendly_name, json.data.ieee_address);
break;
case 'device_joined':
this.log.debug(`handleEvent() type: device_joined name: ${json.data.friendly_name} address: ${json.data.ieee_address}`);
this.emit('device_joined', json.data.friendly_name, json.data.ieee_address);
break;
case 'device_announce':
this.log.debug(`handleEvent() type: device_announce name: ${json.data.friendly_name} address: ${json.data.ieee_address}`);
this.emit('device_announce', json.data.friendly_name, json.data.ieee_address);
break;
case 'device_interview':
this.log.debug(`handleEvent() type: device_interview name: ${json.data.friendly_name} address: ${json.data.ieee_address} status: ${json.data.status} supported: ${json.data.supported}`);
this.emit('device_interview', json.data.friendly_name, json.data.ieee_address, json.data.status, json.data.supported);
break;
}
}
readConfig(filename) {
this.log.debug(`Reading config from ${filename}`);
try {
const rawdata = fs.readFileSync(filename, 'utf-8');
const data = this.tryJsonParse(rawdata);
return data;
}
catch (err) {
this.log.error('readConfig error', err);
return null;
}
}
writeConfig(filename, data) {
this.log.debug(`Writing config to ${filename}`);
try {
const jsonString = JSON.stringify(data, null, 2);
fs.writeFileSync(filename, jsonString);
return true;
}
catch (err) {
this.log.error('writeConfig error', err);
return false;
}
}
emitPayload(entity, data) {
this.emit('MESSAGE-' + entity, data);
}
}