signalk-mqtt-import
Version:
SignalK plugin to selectively import data from MQTT with webapp management interface
755 lines • 31.7 kB
JavaScript
;
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-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 = {
mqttClient: null,
importRules: [],
lastReceivedMessages: new Map(),
selfVesselUrn: null,
rulesFilePath: null,
currentConfig: undefined,
};
plugin.start = function (options) {
app.debug('Starting SignalK MQTT Import Manager plugin');
const config = {
mqttBroker: (options === null || options === void 0 ? void 0 : options.mqttBroker) || 'mqtt://localhost:1883',
mqttClientId: (options === null || options === void 0 ? void 0 : options.mqttClientId) || 'signalk-mqtt-import',
mqttUsername: (options === null || options === void 0 ? void 0 : options.mqttUsername) || '',
mqttPassword: (options === null || options === void 0 ? void 0 : options.mqttPassword) || '',
topicPrefix: (options === null || options === void 0 ? void 0 : options.topicPrefix) || '',
enabled: (options === null || options === void 0 ? void 0 : options.enabled) !== false,
};
state.currentConfig = config;
plugin.config = config;
// Load rules from persistent storage (or migrate from old config)
const migratedRules = migrateOldConfiguration(options);
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.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 () {
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) {
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}`);
subscribeToMQTTTopics();
});
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...');
});
state.mqttClient.on('message', (topic, message) => {
handleMQTTMessage(topic, message);
});
}
catch (error) {
app.debug(`Failed to initialize MQTT client: ${error.message}`);
}
}
// Subscribe to MQTT topics based on import rules
function subscribeToMQTTTopics() {
if (!state.mqttClient || !state.mqttClient.connected) {
return;
}
// Get all unique topics from enabled import rules
const topics = new Set();
state.importRules
.filter(rule => rule.enabled)
.forEach(rule => {
var _a;
let topic = rule.mqttTopic;
// Add topic prefix if configured
if ((_a = state.currentConfig) === null || _a === void 0 ? void 0 : _a.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, message) {
var _a;
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 = null;
for (const r of state.importRules) {
if (!r.enabled)
continue;
let expectedTopic = r.mqttTopic;
if ((_a = state.currentConfig) === null || _a === void 0 ? void 0 : _a.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;
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.message}`);
}
}
// Parse value-only message format
function parseValueOnlyMessage(messageStr, rule, topic) {
try {
let value;
// Try to parse as JSON first
try {
value = JSON.parse(messageStr);
}
catch (_a) {
// 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,
updates: [
{
source: {
label: rule.sourceLabel || '',
type: 'mqtt',
},
timestamp: new Date().toISOString(),
values: [
{
path: path,
value: value,
},
],
},
],
};
}
catch (error) {
app.debug(`Error parsing value-only message: ${error.message}`);
return null;
}
}
// Parse full SignalK message format
function parseFullSignalKMessage(messageStr, rule, topic) {
try {
const parsed = JSON.parse(messageStr);
// If it's already a proper SignalK delta, use it directly
if (parsed.context && parsed.updates) {
return parsed;
}
// Otherwise, try to construct a SignalK delta
const context = rule.signalKContext || parsed.context || extractContextFromTopic(topic);
const path = rule.signalKPath || extractPathFromTopic(topic);
return {
context: context,
updates: [
{
source: {
label: rule.sourceLabel || '',
type: 'mqtt',
},
timestamp: new Date().toISOString(),
values: [
{
path: path,
value: parsed,
},
],
},
],
};
}
catch (error) {
app.debug(`Error parsing full SignalK message: ${error.message}`);
return null;
}
}
// Helper function to convert URN format for MQTT topics
function urnToMqttFormat(urn) {
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) {
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) {
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) {
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, pattern, selfVesselUrn) {
// 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, rule) {
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) {
var _a;
// Remove prefix if present
let cleanTopic = topic;
if ((_a = state.currentConfig) === null || _a === void 0 ? void 0 : _a.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) {
var _a;
// Remove prefix if present
let cleanTopic = topic;
if ((_a = state.currentConfig) === null || _a === void 0 ? void 0 : _a.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, rule) {
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.values) {
update.values.forEach((valueUpdate) => {
valueUpdate.value = rule.transformValue(valueUpdate.value);
});
}
});
}
// Send to SignalK
app.handleMessage(plugin.id, signalKData);
app.debug(`✅ Imported to SignalK: ${signalKData.context} - ${signalKData.updates.length} updates`);
}
catch (error) {
app.debug(`Error sending to SignalK: ${error.message}`);
}
}
// Get default import rules
function getDefaultImportRules() {
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: false, // Disabled by default
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: false, // Disabled by default
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() {
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) {
const express = require('express');
app.debug('registerWithRouter called for MQTT import manager');
// API Routes
// Get current import rules
router.get('/api/rules', (_, res) => {
res.json({
success: true,
rules: state.importRules,
mqttConnected: state.mqttClient ? state.mqttClient.connected : false,
});
});
// Update import 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.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.message });
}
});
// Get MQTT connection status
router.get('/api/mqtt-status', (_, res) => {
var _a, _b;
res.json({
success: true,
connected: state.mqttClient ? state.mqttClient.connected : false,
broker: (_a = state.currentConfig) === null || _a === void 0 ? void 0 : _a.mqttBroker,
clientId: (_b = state.currentConfig) === null || _b === void 0 ? void 0 : _b.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' });
}
res.json({
success: true,
message: 'MQTT connection is active and receiving messages',
});
}
catch (error) {
res
.status(500)
.json({ success: false, error: error.message });
}
});
// Get import statistics
router.get('/api/stats', (_, res) => {
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.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() {
if (!state.rulesFilePath) {
const dataDir = app.getDataDirPath();
state.rulesFilePath = path.join(dataDir, 'mqtt-import-rules.json');
}
return state.rulesFilePath;
}
function loadRulesFromStorage() {
try {
const filePath = getRulesFilePath();
if (fs.existsSync(filePath)) {
const data = fs.readFileSync(filePath, 'utf8');
return JSON.parse(data);
}
}
catch (error) {
app.debug(`Error loading rules from storage: ${error.message}`);
}
return getDefaultImportRules();
}
function saveRulesToStorage(rules) {
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.message}`);
return false;
}
}
function migrateOldConfiguration(options) {
// 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;
};
//# sourceMappingURL=index.js.map