matterbridge-zigbee2mqtt
Version:
Matterbridge zigbee2mqtt plugin
1,036 lines (1,035 loc) • 48 kB
JavaScript
import fs from 'node:fs';
import path from 'node:path';
import * as util from 'node:util';
import * as crypto from 'node:crypto';
import { EventEmitter } from 'node:events';
import { mkdir } from 'node:fs/promises';
import { connectAsync } from 'mqtt';
import { AnsiLogger, rs, db, dn, gn, er, zb, hk, id, idn, ign, REVERSE, REVERSEOFF } from 'node-ansi-logger';
const writeFile = util.promisify(fs.writeFile);
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;
z2mDevices;
z2mGroups;
loggedEntries = 0;
options = {
clientId: 'matterbridge_' + crypto.randomBytes(8).toString('hex'),
keepalive: 60,
protocol: 'mqtt',
protocolVersion: 5,
reconnectPeriod: 5000,
connectTimeout: 60 * 1000,
username: undefined,
password: undefined,
clean: true,
};
constructor(mqttHost, mqttPort, mqttTopic, mqttUsername, mqttPassword, 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;
this.options.protocolVersion = protocolVersion;
if (mqttHost.startsWith('mqtts://')) {
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);
}
catch (error) {
this.log.error(`Error reading the CA certificate from ${ca}:`, error);
}
}
this.options.rejectUnauthorized = rejectUnauthorized !== undefined ? rejectUnauthorized : true;
this.options.protocol = 'mqtts';
if (cert && key) {
try {
fs.accessSync(cert, fs.constants.R_OK);
this.options.cert = fs.readFileSync(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);
}
catch (error) {
this.log.error(`Error reading the client key from ${key}:`, error);
}
}
}
else if (mqttHost.startsWith('mqtt://')) {
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 {
this.log.warn('You are using an unsupported MQTT protocol. Please use mqtt:// or mqtts://.');
}
this.z2mIsAvailabilityEnabled = false;
this.z2mIsOnline = false;
this.z2mPermitJoin = false;
this.z2mPermitJoinTimeout = 0;
this.z2mVersion = '';
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 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}`);
}
}
getUrl() {
return 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--;
}
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;
}
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);
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')) {
const data = this.tryJsonParse(payload.toString());
this.z2mPermitJoin = data.permit_join ? data.permit_join : false;
this.z2mPermitJoinTimeout = data.permit_join_timeout ? data.permit_join_timeout : 0;
this.z2mVersion = data.version ? data.version : '';
this.z2mIsAvailabilityEnabled = data.config.availability ? true : false;
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 => ${data.config.advanced.output}`);
this.log.debug(`Message bridge/info advanced.legacy_api => ${data.config.advanced.legacy_api}`);
this.log.debug(`Message bridge/info advanced.legacy_availability_payload => ${data.config.advanced.legacy_availability_payload}`);
if (data.config.advanced.output === 'attribute')
this.log.error(`Message bridge/info advanced.output must be 'json' or 'attribute_and_json'. Now is ${data.config.advanced.output}`);
if (data.config.advanced.legacy_api === true)
this.log.info(`Message bridge/info advanced.legacy_api is ${data.config.advanced.legacy_api}`);
if (data.config.advanced.legacy_availability_payload === true)
this.log.info(`Message bridge/info advanced.legacy_availability_payload is ${data.config.advanced.legacy_availability_payload}`);
this.emit('info', this.z2mVersion, this.z2mIsAvailabilityEnabled, this.z2mPermitJoin, this.z2mPermitJoinTimeout);
this.emit('bridge-info', data);
if (this.log.logLevel === "debug")
this.writeBufferJSON('bridge-info', payload);
}
else if (topic.startsWith(this.mqttTopic + '/bridge/devices')) {
this.z2mDevices.splice(0, this.z2mDevices.length);
const devices = this.tryJsonParse(payload.toString());
const data = this.tryJsonParse(payload.toString());
if (this.log.logLevel === "debug")
this.writeBufferJSON('bridge-devices', payload);
this.emit('bridge-devices', data);
let index = 1;
for (const device of devices) {
if (device.type === 'Coordinator' && device.supported === true && device.disabled === false && device.interview_completed === true && device.interviewing === false) {
const z2m = {
logName: 'Coordinator',
index: 0,
ieee_address: device.ieee_address,
friendly_name: device.friendly_name,
getPayload: undefined,
description: '',
manufacturer: '',
model_id: '',
vendor: 'zigbee2MQTT',
model: 'coordinator',
date_code: '',
software_build_id: '',
power_source: 'Mains (single phase)',
isAvailabilityEnabled: false,
isOnline: false,
category: '',
hasEndpoints: false,
exposes: [],
options: [],
endpoints: [],
};
this.z2mDevices.push(z2m);
}
if (device.type !== 'Coordinator' && device.supported === true && device.disabled === false && device.interview_completed === true && device.interviewing === false) {
const z2m = {
logName: 'Dev#' + index.toString().padStart(2, '0'),
index: index++,
ieee_address: device.ieee_address,
friendly_name: device.friendly_name,
getPayload: undefined,
description: device.definition.description || '',
manufacturer: device.manufacturer || '',
model_id: device.model_id || '',
vendor: device.definition.vendor || '',
model: device.definition.model || '',
date_code: device.date_code || '',
software_build_id: device.software_build_id || '',
power_source: device.power_source,
isAvailabilityEnabled: false,
isOnline: false,
category: '',
hasEndpoints: false,
exposes: [],
options: [],
endpoints: [],
};
for (const expose of device.definition.exposes) {
if (!expose.property && !expose.name && expose.features && expose.type) {
if (z2m.category === '') {
z2m.category = expose.type;
}
for (const feature of expose.features) {
feature.category = expose.type;
z2m.exposes.push(feature);
if (feature.endpoint) {
z2m.hasEndpoints = true;
}
}
}
else {
expose.category = '';
z2m.exposes.push(expose);
}
}
for (const option of device.definition.options) {
const feature = option;
z2m.options.push(feature);
}
for (const key in device.endpoints) {
const endpoint = device.endpoints[key];
const endpointWithKey = {
...endpoint,
endpoint: key,
};
z2m.endpoints.push(endpointWithKey);
}
this.z2mDevices.push(z2m);
}
}
this.log.debug(`Received ${this.z2mDevices.length} devices`);
this.emit('devices');
}
else if (topic.startsWith(this.mqttTopic + '/bridge/groups')) {
this.z2mGroups.splice(0, this.z2mGroups.length);
const groups = this.tryJsonParse(payload.toString());
const data = this.tryJsonParse(payload.toString());
if (this.log.logLevel === "debug")
this.writeBufferJSON('bridge-groups', payload);
this.emit('bridge-groups', data);
let index = 1;
for (const group of groups) {
const z2m = {
logName: 'Grp#' + index.toString().padStart(2, '0'),
index: index++,
id: group.id,
friendly_name: group.friendly_name,
getPayload: undefined,
isAvailabilityEnabled: false,
isOnline: false,
members: [],
scenes: [],
};
for (const member of group.members) {
z2m.members.push(member);
}
for (const scene of group.scenes) {
z2m.scenes.push(scene);
}
this.z2mGroups.push(z2m);
}
this.log.debug(`Received ${this.z2mGroups.length} groups`);
this.emit('groups');
}
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.loggedEntries < 1000) {
const logEntry = {
entity,
service,
payload: payload.toString(),
};
const filePath = path.join(this.mqttDataPath, 'bridge-payloads.txt');
fs.appendFileSync(filePath, JSON.stringify(logEntry) + '\n');
this.loggedEntries++;
}
const foundDevice = this.z2mDevices.findIndex((device) => device.ieee_address === entity || device.friendly_name === entity);
if (foundDevice !== -1) {
this.handleDeviceMessage(foundDevice, entity, service, payload);
}
else {
const foundGroup = this.z2mGroups.findIndex((group) => group.friendly_name === entity);
if (foundGroup !== -1) {
this.handleGroupMessage(foundGroup, entity, service, payload);
}
else {
try {
this.log.debug('Message for ***unknown*** entity:', entity, 'service:', service, 'payload:', payload);
}
catch {
this.log.debug('Message for ***unknown*** entity:', entity, 'service:', service, 'payload: error');
}
}
}
}
}
getDevice(name) {
return this.z2mDevices.find((device) => device.ieee_address === name || device.friendly_name === name);
}
getGroup(name) {
return this.z2mGroups.find((group) => group.friendly_name === name);
}
handleDeviceMessage(deviceIndex, 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.z2mDevices[deviceIndex].isAvailabilityEnabled = true;
this.z2mDevices[deviceIndex].isOnline = true;
this.emit('availability', entity, true);
this.emit('ONLINE-' + entity);
}
else if (data.state === 'offline') {
this.z2mDevices[deviceIndex].isOnline = false;
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(groupIndex, 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.z2mGroups[groupIndex].isAvailabilityEnabled = true;
this.z2mGroups[groupIndex].isOnline = true;
this.emit('availability', entity, true);
this.emit('ONLINE-' + entity);
}
else if (data.state === 'offline') {
this.z2mGroups[groupIndex].isOnline = false;
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 true;
}
}
emitPayload(entity, data) {
this.emit('MESSAGE-' + entity, data);
}
printDevice(device) {
this.log.debug(`Device - ${dn}${device.friendly_name}${rs}`);
this.log.debug(`IEEE Address: ${device.ieee_address}`);
this.log.debug(`Description: ${device.description}`);
this.log.debug(`Manufacturer: ${device.manufacturer}`);
this.log.debug(`Model ID: ${device.model_id}`);
this.log.debug(`Date Code: ${device.date_code}`);
this.log.debug(`Software Build ID: ${device.software_build_id}`);
this.log.debug(`Power Source: ${device.power_source}`);
this.log.debug(`Availability Enabled: ${device.isAvailabilityEnabled}`);
this.log.debug(`Online: ${device.isOnline}`);
this.log.debug(`Type: ${device.category}`);
const printFeatures = (features, featureType) => {
this.log.debug(`${featureType}:`);
features.forEach((feature) => {
this.log.debug(` Name: ${zb}${feature.name}${rs}`);
this.log.debug(` Description: ${feature.description}`);
this.log.debug(` Property: ${zb}${feature.property}${rs}`);
this.log.debug(` Type: ${feature.type}`);
this.log.debug(` Access: ${feature.access}`);
if (feature.endpoint) {
this.log.debug(` Endpoint: ${feature.endpoint}`);
}
if (feature.unit) {
this.log.debug(` Unit: ${feature.unit}`);
}
if (feature.value_max) {
this.log.debug(` Value Max: ${feature.value_max}`);
}
if (feature.value_min) {
this.log.debug(` Value Min: ${feature.value_min}`);
}
if (feature.value_step) {
this.log.debug(` Value Step: ${feature.value_step}`);
}
if (feature.value_on) {
this.log.debug(` Value On: ${feature.value_on}`);
}
if (feature.value_off) {
this.log.debug(` Value Off: ${feature.value_off}`);
}
if (feature.value_toggle) {
this.log.debug(` Value Toggle: ${feature.value_toggle}`);
}
if (feature.values) {
this.log.debug(` Values: ${feature.values.join(', ')}`);
}
if (feature.presets) {
this.log.debug(` Presets: ${feature.presets.join(', ')}`);
}
this.log.debug('');
});
};
const printEndpoints = (endpoints) => {
endpoints.forEach((endpoint) => {
this.log.debug(`--Endpoint ${endpoint.endpoint}`);
endpoint.bindings.forEach((binding) => {
this.log.debug(`----Bindings: ${binding.cluster}`, binding.target);
});
endpoint.clusters.input.forEach((input) => {
this.log.debug(`----Clusters input: ${input}`);
});
endpoint.clusters.output.forEach((output) => {
this.log.debug(`----Clusters output: ${output}`);
});
endpoint.configured_reportings.forEach((reporting) => {
this.log.debug(`----Reportings: ${reporting.attribute} ${reporting.cluster} ${reporting.minimum_report_interval} ${reporting.maximum_report_interval} ${reporting.reportable_change}`);
});
endpoint.scenes.forEach((scene) => {
this.log.debug(`----Scenes: ID ${scene.id} Name ${scene.name}`);
});
this.log.debug('');
});
};
printFeatures(device.exposes, 'Exposes');
printFeatures(device.options, 'Options');
printEndpoints(device.endpoints);
this.log.debug('');
}
printDevices() {
this.z2mDevices.forEach((device) => {
this.printDevice(device);
});
}
printGroup(group) {
this.log.debug(`Group - ${dn}${group.friendly_name}${rs}`);
this.log.debug(`ID: ${group.id}`);
const printMembers = (members) => {
this.log.debug('Members:');
members.forEach((member) => {
this.log.debug(`--Endpoint ${member.endpoint}`);
this.log.debug(`--IEEE Address ${member.ieee_address}`);
});
};
printMembers(group.members);
const printScenes = (scenes) => {
this.log.debug('Scenes:');
scenes.forEach((scene) => {
this.log.debug(`--ID ${scene.id}`);
this.log.debug(`--Name ${scene.name}`);
});
};
printScenes(group.scenes);
this.log.debug(`Availability Enabled: ${group.isAvailabilityEnabled}`);
this.log.debug(`Online: ${group.isOnline}`);
}
printGroups() {
this.z2mGroups.forEach((group) => {
this.printGroup(group);
});
}
}