const mqtt = require('mqtt'); const config = require('../config/config'); const logger = require('../utils/logger'); const { nodeQueries, positionQueries, messageQueries, telemetryQueries } = require('../database/queries'); class MeshtasticMQTTClient { constructor() { this.client = null; this.connected = false; this.messageCallbacks = []; } connect() { const { broker, username, password, topic, options } = config.mqtt; logger.info(`Connecting to MQTT broker: ${broker}`); // Create MQTT client this.client = mqtt.connect(broker, { ...options, username, password }); // Connection event handlers this.client.on('connect', () => { this.connected = true; logger.info('Connected to MQTT broker'); // Subscribe to Meshtastic topics this.client.subscribe(topic, (err) => { if (err) { logger.error('Failed to subscribe to topic:', err); } else { logger.info(`Subscribed to topic: ${topic}`); } }); }); this.client.on('error', (error) => { logger.error('MQTT connection error:', error); this.connected = false; }); this.client.on('close', () => { this.connected = false; logger.warn('MQTT connection closed'); }); this.client.on('reconnect', () => { logger.info('Reconnecting to MQTT broker...'); }); // Message handler this.client.on('message', (topic, message) => { this.handleMessage(topic, message); }); return this; } handleMessage(topic, message) { try { // Parse the topic to extract information const topicParts = topic.split('/'); // Log raw message for debugging (changed to info for testing) logger.info(`Received MQTT message on topic: ${topic} (${message.length} bytes)`); // Try to parse as JSON first (some messages might be JSON) let payload; try { payload = JSON.parse(message.toString()); this.processJsonMessage(topic, payload); } catch (e) { // If not JSON, treat as protobuf or binary data this.processRawMessage(topic, message); } // Notify subscribers this.notifyCallbacks(topic, message); } catch (error) { logger.error('Error handling MQTT message:', error); } } processJsonMessage(topic, payload) { try { const { from, to, channel, id, sender } = payload; // Extract node ID (convert to hex string if it's a number, fallback to sender if none const fromNode = from ? `!${from.toString(16).padStart(8, '0')}` : sender; const toNode = to ? `!${to.toString(16).padStart(8, '0')}` : null; // Debug log for Aether channel if (topic.includes('Aether')) { logger.info(`Aether message - Type: ${payload.type}, From: ${fromNode}, Channel: ${channel}, Payload: ${JSON.stringify(payload)}`); } // Handle different message types based on actual JSON structure if ((payload.type === 'sendtext' || payload.type === 'text') && payload.payload) { this.handleTextMessage(fromNode, toNode, { ...payload, text: typeof payload.payload === 'string' ? payload.payload : payload.payload.text, id: payload.id, channel: payload.channel, rxSnr: payload.snr, rxRssi: payload.rssi }); } if (payload.type === 'position' && payload.payload) { const pos = payload.payload; this.handlePositionUpdate(fromNode, { latitude: pos.latitude_i / 10000000, longitude: pos.longitude_i / 10000000, altitude: pos.altitude, precisionBits: pos.precision_bits }); } if (payload.type === 'nodeinfo' && payload.payload) { this.handleNodeInfo(fromNode, { shortName: payload.payload.shortname, longName: payload.payload.longname, hardwareModel: payload.payload.hardware, role: payload.payload.role }); } if (payload.type === 'telemetry' && payload.payload) { this.handleTelemetry(fromNode, { deviceMetrics: { batteryLevel: payload.payload.battery_level, voltage: payload.payload.voltage, channelUtilization: payload.payload.channel_utilization, airUtilTx: payload.payload.air_util_tx, uptimeSeconds: payload.payload.uptime_seconds } }); } if (payload.type === 'neighborinfo' && payload.payload) { // Just update last_seen for the node nodeQueries.upsertNode.run( fromNode, null, null, null, null, null, null, // last_heard - will use CURRENT_TIMESTAMP null, null, null, null, null ); } } catch (error) { logger.error('Error processing JSON message:', error); } } processRawMessage(topic, message) { // For protobuf messages, we'll need to decode them // This is a simplified version - real implementation would use protobuf definitions try { // Extract information from topic const topicParts = topic.split('/'); // Basic message logging for debugging logger.debug(`Raw message length: ${message.length} bytes`); } catch (error) { logger.error('Error processing raw message:', error); } } handleTextMessage(fromNode, toNode, data) { try { if (!fromNode) return; logger.info(`Text message from ${fromNode}: ${data.text || '(empty)'}`); // Ensure node exists first (upsert) nodeQueries.upsertNode.run( fromNode, null, null, null, null, null, null, // last_heard - will use CURRENT_TIMESTAMP via COALESCE null, null, null, null, null ); // Insert message - ensure all values are proper types const messageId = data.id ? String(data.id) : null; const channel = typeof data.channel === 'number' ? data.channel : 0; const text = data.text || ''; const rxSnr = typeof data.rxSnr === 'number' ? data.rxSnr : null; const rxRssi = typeof data.rxRssi === 'number' ? data.rxRssi : null; const hopLimit = typeof data.hopLimit === 'number' ? data.hopLimit : null; const wantAck = data.wantAck === true ? 1 : 0; messageQueries.insertMessage.run( messageId, fromNode, toNode, channel, text, null, // rx_time - let database use CURRENT_TIMESTAMP for created_at rxSnr, rxRssi, hopLimit, wantAck ); } catch (error) { logger.error('Error handling text message:', error); } } handlePositionUpdate(nodeId, data) { try { if (!nodeId || !data.latitude || !data.longitude) return; logger.info(`Position update from ${nodeId}: ${data.latitude}, ${data.longitude}`); // Update node nodeQueries.upsertNode.run( nodeId, null, // short_name null, // long_name null, // hardware_model null, // role null, // firmware_version null, // last_heard - will use CURRENT_TIMESTAMP null, // battery_level null, // voltage null, // channel_utilization null, // air_util_tx null // uptime_seconds ); // Insert position positionQueries.insertPosition.run( nodeId, data.latitude, data.longitude, data.altitude || null, data.precisionBits || null ); } catch (error) { logger.error('Error handling position update:', error); } } handleNodeInfo(nodeId, data) { try { if (!nodeId) return; logger.info(`Node info update for ${nodeId}`); // Update node information nodeQueries.upsertNode.run( nodeId, data.shortName || data.user?.shortName || null, data.longName || data.user?.longName || null, data.hardwareModel || data.user?.hwModel || null, data.role || null, data.firmwareVersion || null, null, // last_heard - will use CURRENT_TIMESTAMP null, // battery_level null, // voltage null, // channel_utilization null, // air_util_tx null // uptime_seconds ); } catch (error) { logger.error('Error handling node info:', error); } } handleTelemetry(nodeId, data) { try { if (!nodeId) return; logger.info(`Telemetry update from ${nodeId}`); // Update node with telemetry data if (data.deviceMetrics) { const metrics = data.deviceMetrics; nodeQueries.upsertNode.run( nodeId, null, // short_name null, // long_name null, // hardware_model null, // role null, // firmware_version null, // last_heard - will use CURRENT_TIMESTAMP metrics.batteryLevel || null, metrics.voltage || null, metrics.channelUtilization || null, metrics.airUtilTx || null, metrics.uptimeSeconds || null ); // Insert telemetry record telemetryQueries.insertTelemetry.run( nodeId, metrics.batteryLevel || null, metrics.voltage || null, metrics.channelUtilization || null, metrics.airUtilTx || null, metrics.uptimeSeconds || null, null, // temperature null, // relative_humidity null // barometric_pressure ); } if (data.environmentMetrics) { const env = data.environmentMetrics; telemetryQueries.insertTelemetry.run( nodeId, null, // battery_level null, // voltage null, // channel_utilization null, // air_util_tx null, // uptime_seconds env.temperature || null, env.relativeHumidity || null, env.barometricPressure || null ); } } catch (error) { logger.error('Error handling telemetry:', error); } } // Publish a message to MQTT publish(topic, message) { return new Promise((resolve, reject) => { if (!this.connected) { reject(new Error('MQTT client not connected')); return; } this.client.publish(topic, message, (error) => { if (error) { logger.error('Error publishing message:', error); reject(error); } else { logger.info(`Published message to topic: ${topic}`); resolve(); } }); }); } // Send a text message async sendTextMessage(from, text, channel=0) { try { const message = JSON.stringify({ from: from, channel: channel, type: 'sendtext', payload: text }); // Publish to the appropriate topic await this.publish(config.mqtt.pubTopic, message); return true; } catch (error) { logger.error('Error sending text message:', error); throw error; } } // Register a callback for messages onMessage(callback) { this.messageCallbacks.push(callback); } // Notify all callbacks notifyCallbacks(topic, message) { this.messageCallbacks.forEach(callback => { try { callback(topic, message); } catch (error) { logger.error('Error in message callback:', error); } }); } // Get connection status isConnected() { return this.connected; } // Disconnect disconnect() { if (this.client) { this.client.end(); this.connected = false; logger.info('Disconnected from MQTT broker'); } } } // Create singleton instance const mqttClient = new MeshtasticMQTTClient(); module.exports = mqttClient;