UNPKG

signalk-mqtt-export

Version:

SignalK plugin to selectively export data to MQTT with webapp management interface

552 lines (551 loc) 21.6 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); const fs = __importStar(require("fs-extra")); const path = __importStar(require("path")); const mqtt_1 = require("mqtt"); module.exports = function (app) { const plugin = { id: 'signalk-mqtt-export', name: 'SignalK MQTT Export Manager', description: 'Selectively export SignalK data to MQTT with webapp management interface', schema: {}, start: () => { }, stop: () => { }, registerWithRouter: undefined, }; // Plugin state const state = { mqttClient: null, exportRules: [], activeSubscriptions: new Map(), lastSentValues: new Map(), currentConfig: undefined, unsubscribes: [], }; plugin.start = function (options) { app.debug('Starting SignalK MQTT Export Manager plugin'); const config = { mqttBroker: options?.mqttBroker || 'mqtt://localhost:1883', mqttClientId: options?.mqttClientId || 'signalk-mqtt-export', mqttUsername: options?.mqttUsername || '', mqttPassword: options?.mqttPassword || '', topicPrefix: options?.topicPrefix || '', enabled: options?.enabled !== false, exportRules: options?.exportRules || getDefaultExportRules(), }; state.currentConfig = config; plugin.config = config; state.exportRules = config.exportRules; if (!config.enabled) { app.debug('MQTT Export plugin disabled'); return; } // Initialize MQTT client initializeMQTTClient(config); // Set up SignalK subscriptions based on export rules updateSubscriptions(); app.debug('SignalK MQTT Export Manager plugin started'); }; plugin.stop = function () { app.debug('Stopping SignalK MQTT Export Manager plugin'); // Disconnect MQTT client if (state.mqttClient) { state.mqttClient.end(); state.mqttClient = null; } // Unsubscribe from all SignalK subscriptions state.unsubscribes.forEach(unsubscribe => { if (typeof unsubscribe === 'function') { unsubscribe(); } }); state.unsubscribes = []; state.activeSubscriptions.clear(); state.lastSentValues.clear(); app.debug('SignalK MQTT Export Manager plugin stopped'); }; // Initialize MQTT client function initializeMQTTClient(config) { try { const mqttOptions = { clientId: config.mqttClientId, clean: true, reconnectPeriod: 5000, keepalive: 60, }; if (config.mqttUsername && config.mqttPassword) { mqttOptions.username = config.mqttUsername; mqttOptions.password = config.mqttPassword; } state.mqttClient = (0, mqtt_1.connect)(config.mqttBroker, mqttOptions); state.mqttClient.on('connect', () => { app.debug(`✅ Connected to MQTT broker: ${config.mqttBroker}`); }); state.mqttClient.on('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...'); }); } catch (error) { app.debug(`Failed to initialize MQTT client: ${error.message}`); } } // Update SignalK subscriptions based on export rules function updateSubscriptions() { // Clear existing subscriptions state.unsubscribes.forEach(unsubscribe => { if (typeof unsubscribe === 'function') { unsubscribe(); } }); state.unsubscribes = []; state.activeSubscriptions.clear(); // Group export rules by context for efficient subscriptions const contextGroups = new Map(); state.exportRules .filter(rule => rule.enabled) .forEach(rule => { const context = rule.context || 'vessels.self'; if (!contextGroups.has(context)) { contextGroups.set(context, []); } contextGroups.get(context).push(rule); }); // Create subscriptions for each context group contextGroups.forEach((rules, context) => { const subscription = { context: context, subscribe: rules.map(rule => ({ path: rule.path, policy: 'fixed', period: rule.period || 1000, format: 'delta', })), }; app.debug(`Creating subscription for context ${context} with ${rules.length} paths`); app.subscriptionmanager.subscribe(subscription, state.unsubscribes, (subscriptionError) => { app.debug(`Subscription error for ${context}: ${subscriptionError}`); }, delta => { handleSignalKData(delta, rules); }); state.activeSubscriptions.set(context, rules); }); app.debug(`Active subscriptions: ${state.activeSubscriptions.size} contexts, ${state.exportRules.filter(r => r.enabled).length} total rules`); } // Handle incoming SignalK data function handleSignalKData(delta, contextRules) { if (!delta.updates || !state.mqttClient || !state.mqttClient.connected) { return; } delta.updates.forEach(update => { const typedUpdate = update; if (!typedUpdate.values || !Array.isArray(typedUpdate.values)) return; typedUpdate.values.forEach((valueUpdate) => { // Find matching export rule const rule = contextRules.find(r => { if (!r.enabled) return false; // Check path match (support wildcards) let pathMatch = false; if (r.path === '*') { pathMatch = true; } else if (r.path === valueUpdate.path) { pathMatch = true; } else if (r.path.endsWith('*')) { // Handle wildcard patterns like "navigation*" const prefix = r.path.slice(0, -1); pathMatch = valueUpdate.path.startsWith(prefix); } if (!pathMatch) { return false; } // Check source match const sourceMatch = !r.source || !r.source.trim() || r.source === (typedUpdate.$source || typedUpdate.source?.label); if (!sourceMatch) return false; // Check MMSI exclusion list if (r.excludeMMSI && r.excludeMMSI.trim() && delta.context) { const excludedMMSIs = r.excludeMMSI .split(',') .map(mmsi => mmsi.trim()); const contextHasExcludedMMSI = excludedMMSIs.some(mmsi => delta.context?.includes(mmsi)); if (contextHasExcludedMMSI) { return false; } } return true; }); if (rule) { publishToMQTT(delta, typedUpdate, valueUpdate, rule); } }); }); } // Publish data to MQTT function publishToMQTT(delta, update, valueUpdate, rule) { try { const context = delta.context || 'vessels.self'; const path = valueUpdate.path; const value = valueUpdate.value; // Check if we should only send on change if (rule.sendOnChange) { const changeResult = checkValueChange(context, path, value); if (!changeResult.hasChanged) { return; // Value hasn't changed, don't send } } // Build MQTT topic let topic = ''; if (state.currentConfig?.topicPrefix) { topic = `${state.currentConfig.topicPrefix}/`; } if (rule.topicTemplate) { // Use custom topic template topic += rule.topicTemplate .replace('{context}', context) .replace('{path}', path) .replace(/\./g, '/'); } else { // Default topic structure: context/path topic += `${context}/${path}`.replace(/\./g, '/'); } // Build payload let payload; if (rule.payloadFormat === 'value-only') { payload = typeof value === 'object' ? JSON.stringify(value) : String(value); } else { // Full format: check if custom source label is provided if (rule.sourceLabel && rule.sourceLabel.trim()) { // Construct a new SignalK delta with custom source label const customDelta = { context: context, updates: [ { $source: rule.sourceLabel, timestamp: new Date().toISOString(), values: [ { path: path, value: value, }, ], }, ], }; payload = JSON.stringify(customDelta); } else { // Use original SignalK delta structure (preserves original source) payload = JSON.stringify(delta); } } // Publish to MQTT state.mqttClient.publish(topic, payload, { qos: (rule.qos || 0), retain: rule.retain || false }, err => { if (err) { app.debug(`MQTT publish error: ${err.message}`); } else { app.debug(`✅ Published to MQTT: ${topic} = ${payload.substring(0, 100)}${payload.length > 100 ? '...' : ''}`); } }); } catch (error) { app.debug(`Error publishing to MQTT: ${error.message}`); } } // Check if value has changed function checkValueChange(context, path, value) { const valueKey = `${context}:${path}`; const lastValue = state.lastSentValues.get(valueKey); // Compare values (handle objects and primitives) const currentValueString = typeof value === 'object' ? JSON.stringify(value) : String(value); // If we have a previous value, compare it if (lastValue !== undefined) { const lastValueString = typeof lastValue === 'object' ? JSON.stringify(lastValue) : String(lastValue); if (currentValueString === lastValueString) { // Value hasn't changed return { hasChanged: false, currentValue: value, previousValue: lastValue, }; } } // Store new value for next comparison (either first time or value changed) state.lastSentValues.set(valueKey, value); return { hasChanged: true, currentValue: value, previousValue: lastValue, }; } // Get default export rules (based on actual SignalK data sources) function getDefaultExportRules() { return [ { id: 'all-navigation', name: 'All Navigation Data', context: 'vessels.self', path: 'navigation*', source: '', // All sources enabled: true, period: 1000, qos: 0, retain: false, payloadFormat: 'full', sendOnChange: true, }, { id: 'derived-data', name: 'Derived Data', context: 'vessels.self', path: '*', source: 'derived-data', enabled: true, period: 1000, qos: 0, retain: false, payloadFormat: 'full', sendOnChange: true, }, { id: 'pypilot', name: 'PyPilot Data', context: 'vessels.self', path: '*', source: 'pypilot', enabled: true, period: 1000, qos: 0, retain: false, payloadFormat: 'full', sendOnChange: true, }, { id: 'anchoralarm', name: 'Anchor Alarm', context: 'vessels.self', path: '*', source: 'anchoralarm', enabled: true, period: 1000, qos: 0, retain: false, payloadFormat: 'full', sendOnChange: true, }, { id: 'all-vessels', name: 'All Vessels (AIS)', context: 'vessels.*', path: '*', source: '', // All sources excludeMMSI: '368396230', // Exclude own vessel enabled: true, period: 1000, qos: 0, retain: false, payloadFormat: 'full', sendOnChange: true, }, { id: 'ais-vessels', name: 'AIS Vessels', context: 'vessels.urn:*', path: '*', source: '', // All sources excludeMMSI: '368396230', // Exclude own vessel enabled: true, period: 1000, qos: 0, retain: false, payloadFormat: 'full', sendOnChange: true, }, ]; } // Plugin webapp routes plugin.registerWithRouter = function (router) { const express = require('express'); app.debug('registerWithRouter called for MQTT export manager'); // API Routes // Get current export rules router.get('/api/rules', (_, res) => { res.json({ success: true, rules: state.exportRules, activeSubscriptions: state.activeSubscriptions.size, mqttConnected: state.mqttClient ? state.mqttClient.connected : false, }); }); // Update export rules router.post('/api/rules', (req, res) => { try { const newRules = req.body.rules; if (!Array.isArray(newRules)) { return res .status(400) .json({ success: false, error: 'Rules must be an array' }); } state.exportRules = newRules; if (plugin.config) { plugin.config.exportRules = newRules; } // Save configuration to persistent storage app.savePluginOptions(plugin.config || {}, (err) => { if (err) { app.debug(`Error saving plugin configuration: ${err.message}`); return res.status(500).json({ success: false, error: 'Failed to save configuration', }); } // Update subscriptions with new rules updateSubscriptions(); res.json({ success: true, message: 'Export rules updated and saved', }); }); } catch (error) { res .status(500) .json({ success: false, error: error.message }); } }); // Get MQTT connection status router.get('/api/mqtt-status', (_, res) => { 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', (_, res) => { try { if (!state.mqttClient || !state.mqttClient.connected) { return res .status(503) .json({ success: false, error: 'MQTT not connected' }); } const testTopic = `${state.currentConfig?.topicPrefix || 'test'}/signalk-mqtt-export-test`; const testPayload = JSON.stringify({ test: true, timestamp: new Date().toISOString(), message: 'Test message from SignalK MQTT Export Manager', }); state.mqttClient.publish(testTopic, testPayload, { qos: 0 }); res.json({ success: true, message: 'Test message published', topic: testTopic, }); } catch (error) { res .status(500) .json({ success: false, error: 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 Export Manager web routes registered'); }; // Configuration schema plugin.schema = { type: 'object', properties: { enabled: { type: 'boolean', title: 'Enable MQTT Export', description: 'Enable/disable the MQTT export 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-export', }, 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: '', }, }, }; return plugin; };