node-red-contrib-netpie
Version:
Node-RED module for connecting to NETPIE IoT Platform
478 lines (411 loc) • 14.6 kB
JavaScript
/**
* NETPIE Flow Channel MQTT Client Library
*/
const mqtt = require('mqtt');
const PROCESS_START_TIME = Date.now();
const DEBUG = false;
class FlowChannelMQTT {
constructor(options = {}) {
let that = this;
this.flowchannelkey = options.flowchannelkey || ':';
this.client = null;
this.connected = false;
this.subscriptions = new Map(); // Track subscriptions by topic
this.messageCache = new Map(); // Cache recent messages to prevent duplicates
this.eventListeners = new Map(); // Custom event listener storage
this.mqtthost = options.mqtthost;
let a = options.flowchannelkey.split(':');
this.clientid = `${a[0]}-${PROCESS_START_TIME}`; // Use consistent client ID to avoid multiple sessions
this.username = a[0];
this.password = a[1];
this.debug = options.debug || false;
}
async connect() {
if (DEBUG) {
this.log('Initialize connection...');
}
if (this.client) {
if (DEBUG) {
this.log('Existing connection found, disconnecting first');
}
await this.disconnect();
setTimeout(() => {
this.createConnection();
return this;
}, 250);
}
else {
this.createConnection();
return this;
}
}
createConnection() {
let that = this;
this.client = mqtt.connect(this.mqtthost, {
clientId: this.clientid,
username: this.username,
password: this.password,
clean: true,
keepalive: 15,
reconnectPeriod: 5000,
connectTimeout: 5000,
});
if (DEBUG) {
this.log(`Connecting with client ID: ${this.clientid}`);
}
this.client.on('connect', () => {
this.connected = true;
this.emit('connect');
this.client.subscribe('@private/#');
if (DEBUG) {
this.log('Connected to NETPIE FlowChannel Broker');
}
});
this.client.on('close', () => {
this.connected = false;
this.emit('disconnect');
if (DEBUG) {
this.log('Closed from NETPIE FlowChannel Broker');
}
});
this.client.on('error', (error) => {
this.emit('error', error);
if (DEBUG) {
this.log('MQTT Error:', error);
}
});
this.client.on('message', (topic, payload) => {
this.handleIncomingMessage(topic, payload);
});
}
disconnect() {
return new Promise((resolve) => {
if (this.client) {
this.connected = false;
this.client.removeAllListeners();
if (DEBUG) {
this.log('Starting disconnect process...');
}
if (this.client.connected || this.client.reconnecting) {
this.client.end(true, () => {
this.client = null;
this.subscriptions.clear();
this.messageCache.clear();
if (DEBUG) {
this.log('Disconnected and cleaned up completely');
}
resolve();
});
} else {
this.client = null;
this.subscriptions.clear();
this.messageCache.clear();
if (DEBUG) {
this.log('Client was not connected, cleanup complete');
}
resolve();
}
} else {
if (DEBUG) {
this.log('No client to disconnect');
}
resolve();
}
});
}
async destroy() {
await this.disconnect();
this.removeAllListeners();
if (DEBUG) {
this.log("MQTT connection closed");
}
}
handleIncomingMessage(topic, payload) {
try {
let data;
try {
data = JSON.parse(payload.toString());
} catch (e) {
data = payload;
}
// Use high-resolution timestamp for deduplication within short time window
const now = process.hrtime.bigint();
// Check for duplicates within a very short time window (100ms)
const shortTimeWindow = 100n * 1000000n;
let isDuplicate = false;
for (const [, info] of this.messageCache) {
if (info.topic === topic &&
info.payload === JSON.stringify(data) &&
(now - info.timestamp) < shortTimeWindow) {
isDuplicate = true;
break;
}
}
if (isDuplicate) {
return;
}
const messageKey = `${topic}:${JSON.stringify(data)}:${now}`;
const messageHash = this.hashMessage(messageKey);
this.messageCache.set(messageHash, {
topic: topic,
payload: JSON.stringify(data),
timestamp: now,
createdAt: Date.now()
});
this.cleanupMessageCache();
const deviceid = data && data.deviceid ? data.deviceid : 'unknown';
if (topic.startsWith('@shadow/data/updated')) {
this.emit(`shadow/data/updated:${deviceid}`, data);
this.emit('shadow/data/updated', data);
}
else if (topic.startsWith('@device/status/changed')) {
this.emit(`device/status/changed:${deviceid}`, data);
this.emit('device/status/changed', data);
}
else if (topic.startsWith('@private/shadow/data/get/response')) {
this.emit(`shadow/data/response:${deviceid}`, data);
this.emit('shadow/data/response', data);
}
else if (topic.startsWith('@private/device/status/get/response')) {
this.emit(`device/status/response:${deviceid}`, data);
this.emit('device/status/response', data);
}
else if (topic.startsWith('@msg/')) {
// Extract deviceid from topic path for message events
// Topic format: @msg/projectid/groupid/topic...
const topicParts = topic.split('/');
const msgGroupId = topicParts.length > 2 ? topicParts[2] : 'unknown';
// Remove projectid/groupid from topic - keep only @msg/topic...
let cleanTopic = '@msg';
if (topicParts.length > 3) {
cleanTopic += '/' + topicParts.slice(3).join('/');
}
this.emit(`message:${msgGroupId}`, { topic: cleanTopic, payload: data });
this.emit('message', { topic: cleanTopic, payload: data });
}
else if (topic.startsWith('@feed/data/updated')) {
this.emit(`feed/data/updated:${deviceid}`, data);
this.emit('feed/data/updated', data);
}
else {
this.emit('raw:message', { topic, payload: data });
}
} catch (error) {
this.log('Error handling message:', error);
this.emit('error', error);
}
}
getDeviceInfo(deviceid, devicetoken) {
const topic = `/device/get/${deviceid}:${devicetoken}`;
this.publish(topic, '');
}
getShadow(deviceid, devicetoken) {
const topic = `/shadow/get/${deviceid}:${devicetoken}`;
this.publish(topic, '');
}
updateShadow(deviceid, devicetoken, data) {
const topic = `/shadow/update/${deviceid}:${devicetoken}`;
const payload = typeof data === 'object' ? JSON.stringify(data) : data.toString();
this.publish(topic, payload);
}
publishMessage(deviceid, devicetoken, messageTopic, data) {
const topic = `/msg/topic/${deviceid}:${devicetoken}/${messageTopic}`;
const payload = typeof data === 'object' ? JSON.stringify(data) : data.toString();
this.publish(topic, payload);
}
publishPrivate(deviceid, devicetoken, privateTopic, data) {
const topic = `/private/topic/${deviceid}:${devicetoken}/${privateTopic}`;
const payload = typeof data === 'object' ? JSON.stringify(data) : data.toString();
this.publish(topic, payload);
}
subscribeDevice(deviceid, devicetoken) {
const topics = [
`/shadow/updated/${deviceid}:${devicetoken}`,
`/device/changed/${deviceid}:${devicetoken}`,
`/feed/updated/${deviceid}:${devicetoken}`
];
topics.forEach(topic => {
this.subscribe(topic);
});
}
subscribeMessage(deviceid, devicetoken, messageTopic) {
const topic = `/msg/topic/${deviceid}:${devicetoken}/${messageTopic}`;
this.subscribe(topic);
}
unsubscribeDevice(deviceid, devicetoken) {
const topics = [
`/shadow/updated/${deviceid}:${devicetoken}`,
`/device/changed/${deviceid}:${devicetoken}`,
`/feed/updated/${deviceid}:${devicetoken}`
];
topics.forEach(topic => {
this.unsubscribe(topic);
});
}
unsubscribeMessage(deviceid, devicetoken, messageTopic) {
const topic = `/msg/topic/${deviceid}:${devicetoken}/${messageTopic}`;
this.unsubscribe(topic);
}
subscribe(topic) {
if (!this.connected || !this.client) {
return false;
}
if (this.subscriptions.has(topic)) {
this.subscriptions.set(topic, this.subscriptions.get(topic) + 1);
return true;
}
this.client.subscribe(topic, (err) => {
if (err) {
this.emit('error', err);
} else {
this.subscriptions.set(topic, 1);
}
});
return true;
}
unsubscribe(topic) {
if (!this.connected || !this.client) {
return false;
}
if (!this.subscriptions.has(topic)) {
return true;
}
const count = this.subscriptions.get(topic);
if (count > 1) {
this.subscriptions.set(topic, count - 1);
return true;
}
this.client.unsubscribe(topic, (err) => {
if (err) {
this.emit('error', err);
} else {
this.subscriptions.delete(topic);
}
});
return true;
}
publish(topic, payload) {
if (!this.connected || !this.client) {
return false;
}
this.client.publish(topic, payload, (err) => {
if (err) {
this.log('Publish error:', err);
this.emit('error', err);
}
});
return true;
}
isConnected() {
return this.connected;
}
getSubscriptions() {
return Array.from(this.subscriptions.keys());
}
hashMessage(messageKey) {
let hash = 0;
for (let i = 0; i < messageKey.length; i++) {
const char = messageKey.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return hash.toString();
}
cleanupMessageCache() {
const now = Date.now();
const maxAge = 60 * 1000;
for (const [hash, info] of this.messageCache) {
if (now - info.createdAt > maxAge) {
this.messageCache.delete(hash);
}
}
}
on(event, callback) {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, new Set());
}
const listeners = this.eventListeners.get(event);
for (const existingCallback of listeners) {
if (existingCallback === callback) {
if (DEBUG) {
this.log(`Duplicate callback function object detected for event '${event}', skipping registration`);
}
return this;
}
}
listeners.add(callback);
return this;
}
off(event, callback) {
if (!this.eventListeners.has(event)) {
return this;
}
const listeners = this.eventListeners.get(event);
listeners.delete(callback);
if (listeners.size === 0) {
this.eventListeners.delete(event);
}
if (DEBUG) {
this.log(`Removed callback for event '${event}'`);
}
return this;
}
once(event, callback) {
const onceWrapper = (...args) => {
callback(...args);
this.off(event, onceWrapper);
};
return this.on(event, onceWrapper);
}
emit(event, ...args) {
if (!this.eventListeners.has(event)) {
return false;
}
const listeners = this.eventListeners.get(event);
let eventHandled = false;
for (const callback of listeners) {
try {
callback(...args);
eventHandled = true;
} catch (error) {
this.log(`Error in event listener for '${event}':`, error);
}
}
return eventHandled;
}
removeAllListeners(event) {
if (event) {
this.eventListeners.delete(event);
if (DEBUG) {
this.log(`Removed all listeners for event '${event}'`);
}
} else {
this.eventListeners.clear();
if (DEBUG) {
this.log('Removed all event listeners');
}
}
return this;
}
eventNames() {
return Array.from(this.eventListeners.keys());
}
listenerCount(event) {
if (!this.eventListeners.has(event)) {
return 0;
}
return this.eventListeners.get(event).size;
}
log(...args) {
if (this.debug) {
console.log('[FlowChannelMQTT]', ...args);
}
}
}
function create(options = {}) {
return new FlowChannelMQTT(options);
}
module.exports = {
FlowChannelMQTT,
create
};