UNPKG

signalk-mqtt-import

Version:

SignalK plugin to selectively import data from MQTT with webapp management interface

905 lines (796 loc) 27.4 kB
import * as fs from 'fs-extra'; import * as path from 'path'; import { Router } from 'express'; import { connect } from 'mqtt'; import { SignalKApp, SignalKPlugin, MQTTImportConfig, ImportRule, SignalKDelta, PluginState, TypedRequest, TypedResponse, RulesApiResponse, MQTTStatusApiResponse, StatsApiResponse, ApiResponse, RuleUpdateRequest, MQTTClientOptions, } from './types'; // Global plugin state export = function (app: SignalKApp): SignalKPlugin { const plugin: SignalKPlugin = { id: 'signalk-mqtt-import', name: 'SignalK MQTT Import Manager', description: 'Selectively import SignalK data from MQTT with webapp management interface', schema: {}, start: () => {}, stop: () => {}, registerWithRouter: undefined, }; // Plugin state const state: PluginState = { mqttClient: null, importRules: [], lastReceivedMessages: new Map<string, number>(), selfVesselUrn: null, rulesFilePath: null, currentConfig: undefined, }; plugin.start = function (options: Partial<MQTTImportConfig>): void { app.debug('Starting SignalK MQTT Import Manager plugin'); const config: MQTTImportConfig = { mqttBroker: options?.mqttBroker || 'mqtt://localhost:1883', mqttClientId: options?.mqttClientId || 'signalk-mqtt-import', mqttUsername: options?.mqttUsername || '', mqttPassword: options?.mqttPassword || '', topicPrefix: options?.topicPrefix || '', enabled: options?.enabled !== false, }; state.currentConfig = config; plugin.config = config; // Load rules from persistent storage (or migrate from old config) const migratedRules = migrateOldConfiguration(options as any); state.importRules = migratedRules || loadRulesFromStorage(); app.debug( `Loaded ${state.importRules.length} import rules from persistent storage` ); // Get self vessel URN for proper context mapping try { state.selfVesselUrn = app.selfId || app.getSelfPath('uuid'); app.debug(`Self vessel URN: ${state.selfVesselUrn}`); } catch (error) { app.debug( `Warning: Could not get self vessel URN: ${(error as Error).message}` ); } if (!config.enabled) { app.debug('MQTT Import plugin disabled'); return; } // Initialize MQTT client initializeMQTTClient(config); app.debug('SignalK MQTT Import Manager plugin started'); }; plugin.stop = function (): void { app.debug('Stopping SignalK MQTT Import Manager plugin'); // Disconnect MQTT client if (state.mqttClient) { state.mqttClient.end(); state.mqttClient = null; } state.lastReceivedMessages.clear(); app.debug('SignalK MQTT Import Manager plugin stopped'); }; // Initialize MQTT client function initializeMQTTClient(config: MQTTImportConfig): void { try { const mqttOptions: MQTTClientOptions = { clientId: config.mqttClientId, clean: true, reconnectPeriod: 5000, keepalive: 60, }; if (config.mqttUsername && config.mqttPassword) { mqttOptions.username = config.mqttUsername; mqttOptions.password = config.mqttPassword; } state.mqttClient = connect(config.mqttBroker, mqttOptions); state.mqttClient.on('connect', () => { app.debug(`✅ Connected to MQTT broker: ${config.mqttBroker}`); subscribeToMQTTTopics(); }); state.mqttClient.on('error', (error: Error) => { app.debug(`❌ MQTT client error: ${error.message}`); }); state.mqttClient.on('close', () => { app.debug('🔌 MQTT client disconnected'); }); state.mqttClient.on('reconnect', () => { app.debug('🔄 MQTT client reconnecting...'); }); state.mqttClient.on('message', (topic: string, message: Buffer) => { handleMQTTMessage(topic, message); }); } catch (error) { app.debug( `Failed to initialize MQTT client: ${(error as Error).message}` ); } } // Subscribe to MQTT topics based on import rules function subscribeToMQTTTopics(): void { if (!state.mqttClient || !state.mqttClient.connected) { return; } // Get all unique topics from enabled import rules const topics = new Set<string>(); state.importRules .filter(rule => rule.enabled) .forEach(rule => { let topic = rule.mqttTopic; // Add topic prefix if configured if (state.currentConfig?.topicPrefix) { topic = `${state.currentConfig.topicPrefix}/${topic}`; } // Handle vessels/self/* topics by converting to actual URN format if (topic.includes('vessels/self/') && state.selfVesselUrn) { // Convert vessels/self/* to actual URN format for MQTT subscription const urnTopic = topic.replace( 'vessels/self/', `vessels/${state.selfVesselUrn}/` ); topics.add(urnTopic); app.debug(`Converted vessels/self rule to URN topic: ${urnTopic}`); // Also add underscore format if URN contains colons if (state.selfVesselUrn.includes(':')) { const underscoreUrn = urnToMqttFormat(state.selfVesselUrn); const underscoreTopic = topic.replace( 'vessels/self/', `vessels/${underscoreUrn}/` ); topics.add(underscoreTopic); app.debug(`Also added underscore format: ${underscoreTopic}`); } } else { // Add both underscore and colon formats for URN topics topics.add(topic); if (topic.includes('urn_mrn_imo_mmsi_')) { topics.add( topic.replace(/urn_mrn_imo_mmsi_/g, 'urn:mrn:imo:mmsi:') ); } } }); // Subscribe to all topics app.debug(`📡 Subscribing to ${topics.size} MQTT topics...`); topics.forEach(topic => { state.mqttClient!.subscribe(topic, { qos: 1 }, err => { if (err) { app.debug(`❌ Failed to subscribe to ${topic}: ${err.message}`); } else { app.debug(`✅ Subscribed to MQTT topic: ${topic}`); } }); }); app.debug(`Subscribed to ${topics.size} MQTT topics`); } // Handle incoming MQTT messages function handleMQTTMessage(topic: string, message: Buffer): void { try { const messageStr = message.toString(); // Debug: Log incoming message app.debug(`📥 Received MQTT message on topic: ${topic}`); // Find matching import rule (that doesn't exclude this MMSI) let rule: ImportRule | null = null; for (const r of state.importRules) { if (!r.enabled) continue; let expectedTopic = r.mqttTopic; if (state.currentConfig?.topicPrefix) { expectedTopic = `${state.currentConfig.topicPrefix}/${expectedTopic}`; } // Debug: Log rule matching attempt app.debug( `🔍 Checking rule "${r.name}" with pattern: ${expectedTopic}` ); // First check if topic matches the pattern let matches = false; // Use proper MQTT wildcard matching matches = mqttTopicMatches(topic, expectedTopic, state.selfVesselUrn); // If topic matches, check if MMSI should be excluded if (matches && isMMSIExcluded(topic, r)) { const mmsi = extractMMSIFromUrn(topic.split('/')[1]); app.debug( `🔍 Rule "${r.name}" matches but MMSI ${mmsi} is excluded - continuing search` ); continue; // Continue looking for other rules } // If this rule matches and doesn't exclude, use it if (matches) { rule = r; break; } } if (!rule) { app.debug(`❌ No import rule found for topic: ${topic}`); return; } app.debug(`✅ Rule matched: "${rule.name}" for topic: ${topic}`); // Check for duplicate messages if enabled if (rule.ignoreDuplicates) { const messageKey = `${topic}:${messageStr}`; if (state.lastReceivedMessages.has(messageKey)) { return; // Skip duplicate message } state.lastReceivedMessages.set(messageKey, Date.now()); // Clean up old messages (keep last 1000 messages) if (state.lastReceivedMessages.size > 1000) { const entries = Array.from(state.lastReceivedMessages.entries()); const oldest = entries.slice(0, 500); oldest.forEach(([key]) => state.lastReceivedMessages.delete(key)); } } // Parse the message based on expected format let signalKData: SignalKDelta | null; if (rule.payloadFormat === 'value-only') { signalKData = parseValueOnlyMessage(messageStr, rule, topic); } else { signalKData = parseFullSignalKMessage(messageStr, rule, topic); } if (signalKData) { sendToSignalK(signalKData, rule); app.debug(`📤 Successfully processed message for topic: ${topic}`); } else { app.debug(`⚠️ Failed to parse message for topic: ${topic}`); } } catch (error) { app.debug( `Error handling MQTT message from ${topic}: ${(error as Error).message}` ); } } // Parse value-only message format function parseValueOnlyMessage( messageStr: string, rule: ImportRule, topic: string ): SignalKDelta | null { try { let value: any; // Try to parse as JSON first try { value = JSON.parse(messageStr); } catch { // If not JSON, treat as string/number value = isNaN(Number(messageStr)) ? messageStr : Number(messageStr); } // Extract context and path from topic or rule configuration const context = rule.signalKContext || extractContextFromTopic(topic); const path = rule.signalKPath || extractPathFromTopic(topic); return { context: context as any, updates: [ { source: { label: rule.sourceLabel || '', type: 'mqtt', }, timestamp: new Date().toISOString() as any, values: [ { path: path as any, value: value, }, ], }, ], }; } catch (error) { app.debug( `Error parsing value-only message: ${(error as Error).message}` ); return null; } } // Parse full SignalK message format function parseFullSignalKMessage( messageStr: string, rule: ImportRule, topic: string ): SignalKDelta | null { try { const parsed = JSON.parse(messageStr); // If it's already a proper SignalK delta, use it directly if (parsed.context && parsed.updates) { return parsed as SignalKDelta; } // Otherwise, try to construct a SignalK delta const context = rule.signalKContext || parsed.context || extractContextFromTopic(topic); const path = rule.signalKPath || extractPathFromTopic(topic); return { context: context as any, updates: [ { source: { label: rule.sourceLabel || '', type: 'mqtt', }, timestamp: new Date().toISOString() as any, values: [ { path: path as any, value: parsed, }, ], }, ], }; } catch (error) { app.debug( `Error parsing full SignalK message: ${(error as Error).message}` ); return null; } } // Helper function to convert URN format for MQTT topics function urnToMqttFormat(urn: string): string { if (!urn) return ''; // Convert urn:mrn:imo:mmsi:368396230 to urn_mrn_imo_mmsi_368396230 return urn.replace(/:/g, '_'); } // Helper function to convert MQTT format back to URN function mqttFormatToUrn(mqttFormat: string): string { if (!mqttFormat) return ''; // Convert urn_mrn_imo_mmsi_368396230 to urn:mrn:imo:mmsi:368396230 return mqttFormat.replace(/_/g, ':'); } // Helper function to extract MMSI from URN function extractMMSIFromUrn(urn: string): string | null { if (!urn) return null; // Extract MMSI from urn:mrn:imo:mmsi:368396230 or urn_mrn_imo_mmsi_368396230 const match = urn.match(/urn[_:]+mrn[_:]+imo[_:]+mmsi[_:]+([0-9]+)/); return match ? match[1] : null; } // Helper function to parse MMSI exclusion list function parseMMSIExclusionList(excludeMMSI: string): string[] { if (!excludeMMSI || typeof excludeMMSI !== 'string') return []; return excludeMMSI .split(',') .map(mmsi => mmsi.trim()) .filter(mmsi => mmsi.length > 0); } // Helper function to match MQTT topics with wildcard patterns function mqttTopicMatches( topic: string, pattern: string, selfVesselUrn?: string | null ): boolean { // Handle vessels/self/* patterns by expanding to all possible formats if (pattern.includes('vessels/self/') && selfVesselUrn) { // Create patterns for URN format and underscore format const urnPattern = pattern.replace( 'vessels/self/', `vessels/${selfVesselUrn}/` ); const underscoreUrn = urnToMqttFormat(selfVesselUrn); const underscorePattern = pattern.replace( 'vessels/self/', `vessels/${underscoreUrn}/` ); // Test against all possible patterns return ( mqttTopicMatches( topic, pattern.replace('vessels/self/', 'vessels/+/') ) || mqttTopicMatches(topic, urnPattern) || (underscoreUrn ? mqttTopicMatches(topic, underscorePattern) : false) ); } // Convert MQTT pattern to regex pattern let regexPattern = pattern .replace(/\+/g, '[^/]+') // + matches any characters except / .replace(/#$/, '.*') // # at end matches everything .replace(/#\//, '.*/'); // # in middle matches everything up to next / // Also handle URN format conversion (underscore to colon) const colonPattern = pattern.replace( /urn_mrn_imo_mmsi_/g, 'urn:mrn:imo:mmsi:' ); let colonRegexPattern = ''; if (colonPattern !== pattern) { colonRegexPattern = colonPattern .replace(/\+/g, '[^/]+') .replace(/#$/, '.*') .replace(/#\//, '.*/'); } // Create regex objects with anchors const regex = new RegExp(`^${regexPattern}$`); const colonRegex = colonRegexPattern ? new RegExp(`^${colonRegexPattern}$`) : null; // Test both underscore and colon formats return regex.test(topic) || (colonRegex ? colonRegex.test(topic) : false); } // Helper function to check if MMSI should be excluded function isMMSIExcluded(topic: string, rule: ImportRule): boolean { const exclusionList = parseMMSIExclusionList(rule.excludeMMSI || ''); if (exclusionList.length === 0) return false; // Extract vessel ID from topic const parts = topic.split('/'); if (parts.length < 2 || parts[0] !== 'vessels') return false; const vesselId = parts[1]; const mmsi = extractMMSIFromUrn(vesselId); if (!mmsi) return false; const isExcluded = exclusionList.includes(mmsi); if (isExcluded) { app.debug( `MMSI ${mmsi} excluded by rule "${rule.name}" for topic: ${topic}` ); } return isExcluded; } // Extract SignalK context from MQTT topic function extractContextFromTopic(topic: string): string { // Remove prefix if present let cleanTopic = topic; if (state.currentConfig?.topicPrefix) { cleanTopic = cleanTopic.replace( `${state.currentConfig.topicPrefix}/`, '' ); } const parts = cleanTopic.split('/'); if (parts[0] === 'vessels' && parts.length > 2) { const vesselId = parts[1]; // Check if this is the self vessel's URN (handle both formats) if ( state.selfVesselUrn && (urnToMqttFormat(state.selfVesselUrn) === vesselId || state.selfVesselUrn === vesselId) ) { return 'vessels.self'; } // Handle URN format (both underscore and colon) if (vesselId.startsWith('urn_')) { return `vessels.${mqttFormatToUrn(vesselId)}`; } else if (vesselId.startsWith('urn:')) { return `vessels.${vesselId}`; } // Handle other formats return `vessels.${vesselId}`; } // Fallback to vessels.self return 'vessels.self'; } // Extract SignalK path from MQTT topic function extractPathFromTopic(topic: string): string { // Remove prefix if present let cleanTopic = topic; if (state.currentConfig?.topicPrefix) { cleanTopic = cleanTopic.replace( `${state.currentConfig.topicPrefix}/`, '' ); } // Default path extraction: convert topic to SignalK path // e.g., "vessels/self/navigation/position" -> "navigation.position" const parts = cleanTopic.split('/'); // Remove context parts (vessels/self or vessels/urn_...) if (parts[0] === 'vessels' && parts.length > 2) { return parts.slice(2).join('.'); } // Fallback: use the entire topic as path return cleanTopic.replace(/\//g, '.'); } // Send data to SignalK function sendToSignalK(signalKData: SignalKDelta, rule: ImportRule): void { try { // Validate the data structure if ( !signalKData.context || !signalKData.updates || !Array.isArray(signalKData.updates) ) { app.debug('Invalid SignalK data structure'); return; } // Apply any transformations if configured if (rule.transformValue && typeof rule.transformValue === 'function') { signalKData.updates.forEach(update => { if ((update as any).values) { (update as any).values.forEach((valueUpdate: any) => { valueUpdate.value = rule.transformValue!(valueUpdate.value); }); } }); } // Send to SignalK app.handleMessage(plugin.id, signalKData as any); app.debug( `✅ Imported to SignalK: ${signalKData.context} - ${signalKData.updates.length} updates` ); } catch (error) { app.debug(`Error sending to SignalK: ${(error as Error).message}`); } } // Get default import rules function getDefaultImportRules(): ImportRule[] { return [ { id: 'vessels-all-data', name: 'All Vessel Data (Auto-detect Self)', mqttTopic: 'vessels/+/#', signalKContext: '', // Will be extracted from topic (auto-detect self) signalKPath: '', // Will be extracted from topic sourceLabel: '', enabled: true, payloadFormat: 'full', ignoreDuplicates: true, excludeMMSI: '', }, { id: 'vessels-navigation', name: 'Navigation Data (All Vessels)', mqttTopic: 'vessels/+/navigation/#', signalKContext: '', // Will be extracted from topic (auto-detect self) signalKPath: '', // Will be extracted from topic sourceLabel: '', enabled: true, payloadFormat: 'full', ignoreDuplicates: true, excludeMMSI: '', }, { id: 'vessels-environment', name: 'Environment Data (All Vessels)', mqttTopic: 'vessels/+/environment/#', signalKContext: '', // Will be extracted from topic (auto-detect self) signalKPath: '', // Will be extracted from topic sourceLabel: '', enabled: true, payloadFormat: 'full', ignoreDuplicates: true, excludeMMSI: '', }, { id: 'vessels-electrical', name: 'Electrical Data (All Vessels)', mqttTopic: 'vessels/+/electrical/#', signalKContext: '', // Will be extracted from topic (auto-detect self) signalKPath: '', // Will be extracted from topic sourceLabel: '', enabled: false, // Disabled by default payloadFormat: 'full', ignoreDuplicates: true, excludeMMSI: '', }, { id: 'vessels-propulsion', name: 'Propulsion Data (All Vessels)', mqttTopic: 'vessels/+/propulsion/#', signalKContext: '', // Will be extracted from topic (auto-detect self) signalKPath: '', // Will be extracted from topic sourceLabel: '', enabled: false, // Disabled by default payloadFormat: 'full', ignoreDuplicates: true, excludeMMSI: '', }, ]; } // Update MQTT subscriptions when rules change function updateMQTTSubscriptions(): void { if (state.mqttClient && state.mqttClient.connected) { // Unsubscribe from all topics first state.mqttClient.unsubscribe('#'); // Re-subscribe based on current rules subscribeToMQTTTopics(); } } // Plugin webapp routes plugin.registerWithRouter = function (router: Router): void { const express = require('express'); app.debug('registerWithRouter called for MQTT import manager'); // API Routes // Get current import rules router.get( '/api/rules', (_: TypedRequest, res: TypedResponse<RulesApiResponse>) => { res.json({ success: true, rules: state.importRules, mqttConnected: state.mqttClient ? state.mqttClient.connected : false, }); } ); // Update import rules router.post( '/api/rules', ( req: TypedRequest<RuleUpdateRequest>, res: TypedResponse<ApiResponse> ) => { try { const newRules = req.body.rules; if (!Array.isArray(newRules)) { return res .status(400) .json({ success: false, error: 'Rules must be an array' }); } state.importRules = newRules; // Save rules to persistent storage if (saveRulesToStorage(newRules)) { // Update MQTT subscriptions with new rules updateMQTTSubscriptions(); res.json({ success: true, message: 'Import rules updated and saved to persistent storage', }); } else { res.status(500).json({ success: false, error: 'Failed to save rules to persistent storage', }); } } catch (error) { res .status(500) .json({ success: false, error: (error as Error).message }); } } ); // Get MQTT connection status router.get( '/api/mqtt-status', (_: TypedRequest, res: TypedResponse<MQTTStatusApiResponse>) => { res.json({ success: true, connected: state.mqttClient ? state.mqttClient.connected : false, broker: state.currentConfig?.mqttBroker, clientId: state.currentConfig?.mqttClientId, }); } ); // Test MQTT connection router.post( '/api/test-mqtt', (_: TypedRequest, res: TypedResponse<ApiResponse>) => { try { if (!state.mqttClient || !state.mqttClient.connected) { return res .status(503) .json({ success: false, error: 'MQTT not connected' }); } res.json({ success: true, message: 'MQTT connection is active and receiving messages', }); } catch (error) { res .status(500) .json({ success: false, error: (error as Error).message }); } } ); // Get import statistics router.get( '/api/stats', (_: TypedRequest, res: TypedResponse<StatsApiResponse>) => { try { const stats = { totalRules: state.importRules.length, enabledRules: state.importRules.filter(r => r.enabled).length, messagesReceived: state.lastReceivedMessages.size, mqttConnected: state.mqttClient ? state.mqttClient.connected : false, }; res.json({ success: true, stats: stats }); } catch (error) { res .status(500) .json({ success: false, error: (error as Error).message }); } } ); // Serve static files const publicPath = path.join(__dirname, '../public'); if (fs.existsSync(publicPath)) { router.use(express.static(publicPath)); app.debug(`Static files served from: ${publicPath}`); } app.debug('MQTT Import Manager web routes registered'); }; // Configuration schema plugin.schema = { type: 'object', properties: { enabled: { type: 'boolean', title: 'Enable MQTT Import', description: 'Enable/disable the MQTT import functionality', default: true, }, mqttBroker: { type: 'string', title: 'MQTT Broker URL', description: 'MQTT broker connection string (e.g., mqtt://localhost:1883)', default: 'mqtt://localhost:1883', }, mqttClientId: { type: 'string', title: 'MQTT Client ID', description: 'Unique client identifier for MQTT connection', default: 'signalk-mqtt-import', }, mqttUsername: { type: 'string', title: 'MQTT Username', description: 'Username for MQTT authentication (optional)', default: '', }, mqttPassword: { type: 'string', title: 'MQTT Password', description: 'Password for MQTT authentication (optional)', default: '', }, topicPrefix: { type: 'string', title: 'Topic Prefix', description: 'Optional prefix for all MQTT topics', default: '', }, }, }; // Persistent storage functions function getRulesFilePath(): string { if (!state.rulesFilePath) { const dataDir = app.getDataDirPath(); state.rulesFilePath = path.join(dataDir, 'mqtt-import-rules.json'); } return state.rulesFilePath; } function loadRulesFromStorage(): ImportRule[] { try { const filePath = getRulesFilePath(); if (fs.existsSync(filePath)) { const data = fs.readFileSync(filePath, 'utf8'); return JSON.parse(data) as ImportRule[]; } } catch (error) { app.debug( `Error loading rules from storage: ${(error as Error).message}` ); } return getDefaultImportRules(); } function saveRulesToStorage(rules: ImportRule[]): boolean { try { const filePath = getRulesFilePath(); fs.writeFileSync(filePath, JSON.stringify(rules, null, 2)); app.debug(`Rules saved to: ${filePath}`); return true; } catch (error) { app.debug(`Error saving rules to storage: ${(error as Error).message}`); return false; } } function migrateOldConfiguration(options: any): ImportRule[] | null { // Migrate rules from old plugin config if they exist if (options.importRules && Array.isArray(options.importRules)) { app.debug( 'Migrating import rules from plugin configuration to persistent storage' ); saveRulesToStorage(options.importRules); return options.importRules; } return null; } return plugin; };