UNPKG

signalk-mqtt-import

Version:

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

1,174 lines (1,063 loc) โ€ข 43.7 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="icon" type="image/jpeg" href="mqtt_import.png"> <title>Zennora MQTT Import Manager</title> <style> body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; max-width: 1400px; margin: 0 auto; padding: 20px; background-color: #f5f5f5; } .header { background: linear-gradient(135deg, #e60909 0%, #35f705 100%); color: white; padding: 30px; border-radius: 10px; margin-bottom: 30px; text-align: center; } .header h1 { margin: 0; font-size: 2.5em; } .header p { margin: 10px 0 0 0; opacity: 0.9; } .card { background: white; border-radius: 8px; padding: 8px 12px; margin-bottom: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } .card h3 { margin-top: 0; margin-bottom: 8px; font-size: 1.1em; cursor: pointer; user-select: none; display: flex; align-items: center; justify-content: space-between; } .card h3:hover { opacity: 0.8; } .collapse-icon { font-size: 0.8em; transition: transform 0.3s; } .collapse-icon.collapsed { transform: rotate(-90deg); } .card-content { overflow: hidden; transition: max-height 0.3s ease-out, opacity 0.3s ease-out; max-height: 2000px; opacity: 1; } .card-content.collapsed { max-height: 0; opacity: 0; } .status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; margin-bottom: 8px; } .status-card { background: #f8f9fa; padding: 8px; border-radius: 6px; text-align: center; border: 1px solid #dee2e6; } .status-value { font-size: 1.5em; font-weight: bold; color: #2196F3; display: block; } .status-label { color: #6c757d; margin-top: 3px; font-size: 0.9em; } .status-connected { color: #28a745; } .status-disconnected { color: #dc3545; } .btn { background: #2196F3; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; margin: 3px; transition: background-color 0.3s; } .btn:hover { background: #1976D2; } .btn:disabled { background: #ccc; cursor: not-allowed; } .btn-success { background: #28a745; } .btn-success:hover { background: #218838; } .btn-danger { background: #dc3545; } .btn-danger:hover { background: #c82333; } .btn-warning { background: #ffc107; color: #212529; } .btn-warning:hover { background: #e0a800; } .rules-table { width: 100%; border-collapse: collapse; margin-top: 8px; } .rules-table th, .rules-table td { padding: 6px 8px; text-align: left; border-bottom: 1px solid #ddd; font-size: 0.9em; } .rules-table th { background-color: #f8f9fa; font-weight: 600; } .rules-table tr:hover { background-color: #f5f5f5; } .switch { position: relative; display: inline-block; width: 44px; height: 24px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 24px; } .slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; } input:checked + .slider { background-color: #2196F3; } input:checked + .slider:before { transform: translateX(20px); } .form-group { margin-bottom: 15px; } .form-group label { display: block; margin-bottom: 5px; font-weight: 600; color: #555; } .form-group input, .form-group select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; } .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); } .modal-content { background-color: white; margin: 5% auto; padding: 30px; border-radius: 10px; width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto; } .close { color: #aaa; float: right; font-size: 28px; font-weight: bold; cursor: pointer; } .close:hover { color: black; } .logs { background: #1e1e1e; color: #f1f1f1; padding: 8px; border-radius: 6px; font-family: 'Courier New', monospace; font-size: 11px; height: 150px; overflow-y: auto; overflow-x: hidden; white-space: pre-wrap; } .tag { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; font-weight: 500; margin: 1px; } .tag-enabled { background-color: #d4edda; color: #155724; } .tag-disabled { background-color: #f8d7da; color: #721c24; } .tag-context { background-color: #d1ecf1; color: #0c5460; } .tag-source { background-color: #fff3cd; color: #856404; } .tag-topic { background-color: #e2e3e5; color: #41464b; } .info-box { display: none; position: fixed; top: 20px; right: 20px; min-width: 300px; max-width: 500px; border-radius: 6px; padding: 15px 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 2000; animation: slideIn 0.3s ease-out; } .info-box.show { display: block; } .info-box.info { background: #e3f2fd; border: 1px solid #2196F3; } .info-box.info h4 { color: #1976D2; } .info-box.success { background: #d4edda; border: 1px solid #28a745; } .info-box.success h4 { color: #155724; } .info-box.error { background: #f8d7da; border: 1px solid #dc3545; } .info-box.error h4 { color: #721c24; } .info-box h4 { margin: 0 0 8px 0; font-size: 16px; } .info-box p { margin: 0; font-size: 14px; } @keyframes slideIn { from { transform: translateX(400px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } .test-results { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 12px; font-family: 'Courier New', monospace; font-size: 12px; max-height: 300px; overflow-y: auto; } .test-results.success { background: #d4edda; border-color: #28a745; } .test-results.error { background: #f8d7da; border-color: #dc3545; color: #721c24; } .test-path { padding: 4px 0; border-bottom: 1px solid #ddd; } .test-path:last-child { border-bottom: none; } .test-path-name { color: #0066cc; font-weight: 600; } .test-path-value { color: #333; margin-left: 8px; } </style> </head> <body> <div class="header"> <h1> <img src="mqtt_import.png" alt="Zennora" style="height: 80px; vertical-align: middle; margin-right: 10px; border-radius: 5px;"> SignalK MQTT Import Manager</h1> <p>Import SignalK data from MQTT topics with flexible rules</p> </div> <!-- Status Notification --> <div id="statusNotification" class="info-box"> <h4 id="notificationTitle"></h4> <p id="notificationMessage"></p> </div> <!-- Status Overview --> <div class="card"> <h3 onclick="toggleCard(this)"> <span>๐Ÿ“Š Status Overview</span> <span class="collapse-icon">โ–ผ</span> </h3> <div class="card-content"> <div class="status-grid"> <div class="status-card"> <span id="mqttStatus" class="status-value status-disconnected">Disconnected</span> <div class="status-label">MQTT Connection</div> </div> <div class="status-card"> <span id="activeRules" class="status-value">0</span> <div class="status-label">Active Rules</div> </div> <div class="status-card"> <span id="messagesReceived" class="status-value">0</span> <div class="status-label">Messages Received</div> </div> <div class="status-card"> <span id="totalRules" class="status-value">0</span> <div class="status-label">Total Rules</div> </div> </div> <div> <button class="btn btn-success" onclick="refreshStatus()">๐Ÿ”„ Refresh Status</button> <button class="btn btn-warning" onclick="testMQTT()">๐Ÿงช Test MQTT</button> </div> </div> </div> <!-- Import Rules Management --> <div class="card"> <h3 onclick="toggleCard(this)"> <span>๐Ÿ“ฅ Import Rules</span> <span class="collapse-icon">โ–ผ</span> </h3> <div class="card-content"> <div> <button class="btn btn-success" onclick="showAddRuleModal()">โž• Add Rule</button> <button class="btn" onclick="refreshRules()">๐Ÿ”„ Refresh Rules</button> </div> <div id="rulesContainer"> <table class="rules-table"> <thead> <tr> <th>Enabled</th> <th>Name</th> <th>MQTT Topic</th> <th>SignalK Context</th> <th>SignalK Path</th> <th>Source Label</th> <th>Payload Format</th> <th>Ignore Duplicates</th> <th>Exclude MMSI</th> <th>Actions</th> </tr> </thead> <tbody id="rulesTableBody"> <!-- Rules will be populated here --> </tbody> </table> </div> </div> </div> <!-- Activity Log --> <div class="card"> <h3 onclick="toggleCard(this)"> <span>๐Ÿ“ Activity Log</span> <span class="collapse-icon">โ–ผ</span> </h3> <div class="card-content"> <div id="activityLog" class="logs"> Activity logs will appear here... </div> </div> </div> <!-- Add/Edit Rule Modal --> <div id="ruleModal" class="modal"> <div class="modal-content"> <span class="close" onclick="closeRuleModal()">&times;</span> <h2 id="modalTitle">Add Import Rule</h2> <form id="ruleForm"> <div class="form-group"> <label for="ruleName">Rule Name:</label> <input type="text" id="ruleName" name="name" required> </div> <div class="form-group"> <label for="ruleMqttTopic">MQTT Topic:</label> <input type="text" id="ruleMqttTopic" name="mqttTopic" required placeholder="e.g., vessels/self/navigation/+, vessels/+/navigation/position"> <small>Use + for single-level wildcard, # for multi-level wildcard</small> </div> <div class="form-group"> <label for="ruleSignalKContext">SignalK Context (Optional):</label> <input type="text" id="ruleSignalKContext" name="signalKContext" placeholder="e.g., vessels.self (leave empty to extract from topic)"> </div> <div class="form-group"> <label for="ruleSignalKPath">SignalK Path (Optional):</label> <input type="text" id="ruleSignalKPath" name="signalKPath" placeholder="e.g., navigation.position (leave empty to extract from topic)"> </div> <div class="form-group"> <label for="ruleSourceLabel">Source Label:</label> <input type="text" id="ruleSourceLabel" name="sourceLabel" placeholder="e.g., mqtt-import, remote-sensor (leave blank for default)"> </div> <div class="form-group"> <label for="rulePayloadFormat">Payload Format:</label> <select id="rulePayloadFormat" name="payloadFormat"> <option value="full">Full SignalK Structure</option> <option value="value-only">Value Only</option> <option value="json-object">JSON Object (each key becomes a path)</option> </select> </div> <div class="form-group"> <label> <input type="checkbox" id="ruleEnabled" name="enabled" checked> Rule Enabled </label> </div> <div class="form-group"> <label> <input type="checkbox" id="ruleIgnoreDuplicates" name="ignoreDuplicates" checked> Ignore Duplicate Messages </label> </div> <div class="form-group"> <label for="ruleExcludeMMSI">Exclude MMSI Numbers:</label> <input type="text" id="ruleExcludeMMSI" name="excludeMMSI" placeholder="e.g., 123456789, 987654321"> <small>Comma-separated list of MMSI numbers to exclude from this rule</small> </div> <!-- Payload Tester --> <div class="form-group" style="border-top: 2px solid #dee2e6; padding-top: 15px; margin-top: 20px;"> <h4 style="margin-top: 0;">Test Payload Format</h4> <label for="testPayload">Sample Payload:</label> <textarea id="testPayload" rows="6" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-family: 'Courier New', monospace; font-size: 12px;" placeholder='Paste sample data here, e.g.: { "battery": 100, "contact": true, "temperature": 36 }'></textarea> <div style="margin-top: 8px;"> <button type="button" class="btn" onclick="testPayloadFormat()">Test Format</button> <button type="button" class="btn btn-success" onclick="testAndSendToSignalK()">Test & Send to SignalK</button> <button type="button" class="btn" onclick="clearTestResults()">Clear</button> </div> <div id="testResults" style="margin-top: 10px;"></div> </div> <div> <button type="submit" class="btn btn-success">Save Rule</button> <button type="button" class="btn" onclick="closeRuleModal()">Cancel</button> </div> </form> </div> </div> <script> let currentRules = []; let editingRuleIndex = -1; let notificationTimeout = null; // Show status notification function showNotification(title, message, type = 'info', duration = 3000) { const notification = document.getElementById('statusNotification'); const titleEl = document.getElementById('notificationTitle'); const messageEl = document.getElementById('notificationMessage'); // Clear any existing timeout if (notificationTimeout) { clearTimeout(notificationTimeout); } // Remove all type classes notification.classList.remove('info', 'success', 'error'); // Set content and type titleEl.textContent = title; messageEl.textContent = message; notification.classList.add(type, 'show'); // Auto-hide after duration if (duration > 0) { notificationTimeout = setTimeout(() => { notification.classList.remove('show'); }, duration); } } // Toggle card collapse/expand function toggleCard(headerElement) { const icon = headerElement.querySelector('.collapse-icon'); const cardContent = headerElement.nextElementSibling; icon.classList.toggle('collapsed'); cardContent.classList.toggle('collapsed'); } // Initialize the page document.addEventListener('DOMContentLoaded', function() { refreshStatus(); refreshRules(); // Auto-refresh every 10 seconds setInterval(refreshStatus, 10000); }); // Refresh status information async function refreshStatus() { try { const response = await fetch('/plugins/signalk-mqtt-import/api/mqtt-status'); const data = await response.json(); if (data.success) { document.getElementById('mqttStatus').textContent = data.connected ? 'Connected' : 'Disconnected'; document.getElementById('mqttStatus').className = data.connected ? 'status-value status-connected' : 'status-value status-disconnected'; } // Get statistics const statsResponse = await fetch('/plugins/signalk-mqtt-import/api/stats'); const statsData = await statsResponse.json(); if (statsData.success) { document.getElementById('activeRules').textContent = statsData.stats.enabledRules; document.getElementById('messagesReceived').textContent = statsData.stats.messagesReceived; document.getElementById('totalRules').textContent = statsData.stats.totalRules; } addLog('Status refreshed successfully'); } catch (error) { addLog('Error refreshing status: ' + error.message); } } // Refresh import rules async function refreshRules() { try { const response = await fetch('/plugins/signalk-mqtt-import/api/rules'); const data = await response.json(); if (data.success) { currentRules = data.rules; displayRules(); addLog('Rules refreshed successfully'); } } catch (error) { addLog('Error refreshing rules: ' + error.message); } } // Display rules in the table function displayRules() { const tbody = document.getElementById('rulesTableBody'); tbody.innerHTML = ''; currentRules.forEach((rule, index) => { const row = document.createElement('tr'); row.innerHTML = ` <td> <label class="switch"> <input type="checkbox" ${rule.enabled ? 'checked' : ''} onchange="toggleRule(${index})"> <span class="slider"></span> </label> </td> <td>${rule.name}</td> <td><span class="tag tag-topic">${rule.mqttTopic}</span></td> <td><span class="tag tag-context">${rule.signalKContext || 'Auto'}</span></td> <td><code>${rule.signalKPath || 'Auto'}</code></td> <td><span class="tag tag-source">${rule.sourceLabel}</span></td> <td><span class="tag">${rule.payloadFormat}</span></td> <td><span class="tag ${rule.ignoreDuplicates ? 'tag-enabled' : 'tag-disabled'}">${rule.ignoreDuplicates ? 'Yes' : 'No'}</span></td> <td><span class="tag tag-topic">${rule.excludeMMSI || 'None'}</span></td> <td> <button class="btn" onclick="editRule(${index})">โœ๏ธ Edit</button> <button class="btn btn-danger" onclick="deleteRule(${index})">๐Ÿ—‘๏ธ Delete</button> </td> `; tbody.appendChild(row); }); } // Toggle rule enabled/disabled async function toggleRule(index) { const rule = currentRules[index]; const newState = !rule.enabled; const ruleName = rule.name; // Update local state currentRules[index].enabled = newState; displayRules(); // Save to server try { await saveRulesToServer(); addLog(`Rule "${ruleName}" ${newState ? 'enabled' : 'disabled'}`); showNotification('Success', `Rule "${ruleName}" ${newState ? 'enabled' : 'disabled'}`, 'success', 2000); } catch (error) { // Revert on failure currentRules[index].enabled = !newState; displayRules(); addLog(`Failed to ${newState ? 'enable' : 'disable'} rule "${ruleName}": ${error.message}`); showNotification('Error', `Failed to ${newState ? 'enable' : 'disable'} rule`, 'error', 5000); } } // Show add rule modal function showAddRuleModal() { editingRuleIndex = -1; document.getElementById('modalTitle').textContent = 'Add Import Rule'; document.getElementById('ruleForm').reset(); document.getElementById('ruleEnabled').checked = true; document.getElementById('ruleIgnoreDuplicates').checked = true; document.getElementById('ruleSourceLabel').value = ''; document.getElementById('ruleModal').style.display = 'block'; } // Edit rule function editRule(index) { editingRuleIndex = index; const rule = currentRules[index]; document.getElementById('modalTitle').textContent = 'Edit Import Rule'; document.getElementById('ruleName').value = rule.name; document.getElementById('ruleMqttTopic').value = rule.mqttTopic; document.getElementById('ruleSignalKContext').value = rule.signalKContext || ''; document.getElementById('ruleSignalKPath').value = rule.signalKPath || ''; document.getElementById('ruleSourceLabel').value = rule.sourceLabel || ''; document.getElementById('rulePayloadFormat').value = rule.payloadFormat || 'full'; document.getElementById('ruleEnabled').checked = rule.enabled; document.getElementById('ruleIgnoreDuplicates').checked = rule.ignoreDuplicates !== false; document.getElementById('ruleExcludeMMSI').value = rule.excludeMMSI || ''; document.getElementById('ruleModal').style.display = 'block'; } // Delete rule async function deleteRule(index) { const ruleName = currentRules[index].name; if (!confirm(`Are you sure you want to delete rule "${ruleName}"?`)) { return; } // Store the deleted rule in case we need to restore it const deletedRule = currentRules[index]; // Remove from local state currentRules.splice(index, 1); displayRules(); // Save to server try { await saveRulesToServer(); addLog(`Rule "${ruleName}" deleted`); showNotification('Success', `Rule "${ruleName}" deleted`, 'success', 2000); } catch (error) { // Restore on failure currentRules.splice(index, 0, deletedRule); displayRules(); addLog(`Failed to delete rule "${ruleName}": ${error.message}`); showNotification('Error', `Failed to delete rule "${ruleName}"`, 'error', 5000); } } // Close rule modal function closeRuleModal() { document.getElementById('ruleModal').style.display = 'none'; clearTestResults(); } // Handle rule form submission document.getElementById('ruleForm').addEventListener('submit', async function(e) { e.preventDefault(); const formData = new FormData(e.target); const rule = { id: editingRuleIndex >= 0 ? currentRules[editingRuleIndex].id : Date.now().toString(), name: formData.get('name'), mqttTopic: formData.get('mqttTopic'), signalKContext: formData.get('signalKContext'), signalKPath: formData.get('signalKPath'), sourceLabel: formData.get('sourceLabel'), payloadFormat: formData.get('payloadFormat'), enabled: document.getElementById('ruleEnabled').checked, ignoreDuplicates: document.getElementById('ruleIgnoreDuplicates').checked, excludeMMSI: formData.get('excludeMMSI') }; const isEditing = editingRuleIndex >= 0; const actionName = isEditing ? 'updated' : 'added'; let oldRule = null; // Update local state if (isEditing) { oldRule = currentRules[editingRuleIndex]; currentRules[editingRuleIndex] = rule; } else { currentRules.push(rule); } displayRules(); closeRuleModal(); // Save to server try { await saveRulesToServer(); addLog(`Rule "${rule.name}" ${actionName}`); showNotification('Success', `Rule "${rule.name}" ${actionName} successfully`, 'success', 2000); } catch (error) { // Revert on failure if (isEditing) { currentRules[editingRuleIndex] = oldRule; } else { currentRules.pop(); } displayRules(); addLog(`Failed to ${actionName.slice(0, -1)} rule "${rule.name}": ${error.message}`); showNotification('Error', `Failed to ${actionName.slice(0, -1)} rule "${rule.name}"`, 'error', 5000); } }); // Save rules to server (internal helper function) async function saveRulesToServer() { const response = await fetch('/plugins/signalk-mqtt-import/api/rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rules: currentRules }) }); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to save rules'); } // Refresh status after successful save refreshStatus(); return data; } // Test MQTT connection async function testMQTT() { try { const response = await fetch('/plugins/signalk-mqtt-import/api/test-mqtt', { method: 'POST' }); const data = await response.json(); if (data.success) { showNotification('MQTT Test', data.message, 'success'); addLog('MQTT test successful: ' + data.message); } else { showNotification('MQTT Test Failed', data.error, 'error', 5000); addLog('MQTT test failed: ' + data.error); } } catch (error) { showNotification('MQTT Test Error', error.message, 'error', 5000); addLog('MQTT test error: ' + error.message); } } // Add log entry function addLog(message) { const logContainer = document.getElementById('activityLog'); const timestamp = new Date().toLocaleTimeString(); const logEntry = `[${timestamp}] ${message}\n`; logContainer.textContent += logEntry; logContainer.scrollTop = logContainer.scrollHeight; } // Close modal when clicking outside window.onclick = function(event) { const modal = document.getElementById('ruleModal'); if (event.target === modal) { closeRuleModal(); } } // Test payload format function testPayloadFormat() { const payloadInput = document.getElementById('testPayload').value.trim(); const resultsDiv = document.getElementById('testResults'); if (!payloadInput) { resultsDiv.innerHTML = '<div class="test-results error">Please enter sample payload data</div>'; return; } const format = document.getElementById('rulePayloadFormat').value; const mqttTopic = document.getElementById('ruleMqttTopic').value || 'test/topic'; const signalKContext = document.getElementById('ruleSignalKContext').value; const signalKPath = document.getElementById('ruleSignalKPath').value; // Trim and default source label similar to context and path let sourceLabel = document.getElementById('ruleSourceLabel').value; if (!sourceLabel || sourceLabel.trim() === '') { sourceLabel = 'mqtt-import-test'; } try { let results; switch (format) { case 'value-only': results = testValueOnly(payloadInput, mqttTopic, signalKContext, signalKPath); break; case 'json-object': results = testJsonObject(payloadInput, mqttTopic, signalKContext, signalKPath); break; case 'full': results = testFullSignalK(payloadInput); break; default: results = { error: 'Unknown format' }; } // Add source label to results results.sourceLabel = sourceLabel; displayTestResults(results); } catch (error) { resultsDiv.innerHTML = `<div class="test-results error"><strong>Error:</strong> ${error.message}</div>`; } } // Test value-only format function testValueOnly(payload, topic, contextOverride, pathOverride) { let value; try { value = JSON.parse(payload); } catch { value = isNaN(Number(payload)) ? payload : Number(payload); } // Use override if provided and not empty, otherwise extract from topic, default to vessels.self let context = contextOverride && contextOverride.trim() !== '' ? contextOverride : extractContextFromTopicClient(topic); if (!context || context.trim() === '') { context = 'vessels.self'; } // Use override if provided and not empty, otherwise extract from topic, default to test.mqtt let path = pathOverride && pathOverride.trim() !== '' ? pathOverride : extractPathFromTopicClient(topic); if (!path || path.trim() === '') { path = 'test.mqtt'; } return { context: context, paths: [{ path: path, value: value }] }; } // Test JSON object format function testJsonObject(payload, topic, contextOverride, pathOverride) { const jsonObject = JSON.parse(payload); if (typeof jsonObject !== 'object' || jsonObject === null || Array.isArray(jsonObject)) { throw new Error('JSON object format requires a valid JSON object (not array or primitive)'); } // Use override if provided and not empty, otherwise extract from topic, default to vessels.self let context = contextOverride && contextOverride.trim() !== '' ? contextOverride : extractContextFromTopicClient(topic); if (!context || context.trim() === '') { context = 'vessels.self'; } // Use override if provided and not empty, otherwise extract from topic, default to test.mqtt let basePath = pathOverride && pathOverride.trim() !== '' ? pathOverride : extractPathFromTopicClient(topic); if (!basePath || basePath.trim() === '') { basePath = 'test.mqtt'; } const paths = Object.entries(jsonObject).map(([key, value]) => ({ path: `${basePath}.${key}`, value: value })); return { context: context, paths: paths }; } // Test full SignalK format function testFullSignalK(payload) { const parsed = JSON.parse(payload); if (parsed.context && parsed.updates) { // Extract paths from updates const paths = []; parsed.updates.forEach(update => { if (update.values) { update.values.forEach(v => { paths.push({ path: v.path, value: v.value }); }); } }); return { context: parsed.context, paths: paths, fullDelta: true }; } throw new Error('Full SignalK format requires a delta with context and updates'); } // Client-side version of extractContextFromTopic function extractContextFromTopicClient(topic) { const parts = topic.split('/'); if (parts[0] === 'vessels' && parts.length > 2) { const vesselId = parts[1]; if (vesselId === 'self' || vesselId === '+') { return 'vessels.self'; } return `vessels.${vesselId}`; } return 'vessels.self'; } // Client-side version of extractPathFromTopic function extractPathFromTopicClient(topic) { const parts = topic.split('/'); if (parts[0] === 'vessels' && parts.length > 2) { return parts.slice(2).join('.'); } return topic.replace(/\//g, '.'); } // Display test results function displayTestResults(results) { const resultsDiv = document.getElementById('testResults'); if (results.error) { resultsDiv.innerHTML = `<div class="test-results error"><strong>Error:</strong> ${results.error}</div>`; return; } let html = '<div class="test-results success">'; html += `<div style="margin-bottom: 10px;"><strong>Context:</strong> ${results.context}</div>`; if (results.sourceLabel) { html += `<div style="margin-bottom: 10px;"><strong>Source:</strong> ${results.sourceLabel}</div>`; } if (results.fullDelta) { html += '<div style="margin-bottom: 8px; color: #28a745;"><em>Valid SignalK Delta format detected</em></div>'; } html += `<div style="margin-bottom: 8px;"><strong>Paths (${results.paths.length}):</strong></div>`; results.paths.forEach(item => { const valueStr = typeof item.value === 'object' ? JSON.stringify(item.value) : String(item.value); html += `<div class="test-path">`; html += `<span class="test-path-name">${item.path}</span>`; html += `<span class="test-path-value">= ${valueStr}</span>`; html += `</div>`; }); html += '</div>'; resultsDiv.innerHTML = html; } // Clear test results function clearTestResults() { document.getElementById('testPayload').value = ''; document.getElementById('testResults').innerHTML = ''; } // Test and send to SignalK server async function testAndSendToSignalK() { const payloadInput = document.getElementById('testPayload').value.trim(); const resultsDiv = document.getElementById('testResults'); if (!payloadInput) { resultsDiv.innerHTML = '<div class="test-results error">Please enter sample payload data</div>'; return; } const format = document.getElementById('rulePayloadFormat').value; const mqttTopic = document.getElementById('ruleMqttTopic').value || 'test/topic'; const signalKContext = document.getElementById('ruleSignalKContext').value; const signalKPath = document.getElementById('ruleSignalKPath').value; // Trim and default source label similar to context and path let sourceLabel = document.getElementById('ruleSourceLabel').value; if (!sourceLabel || sourceLabel.trim() === '') { sourceLabel = 'mqtt-import-test'; } try { // First, parse and display the test results let results; switch (format) { case 'value-only': results = testValueOnly(payloadInput, mqttTopic, signalKContext, signalKPath); break; case 'json-object': results = testJsonObject(payloadInput, mqttTopic, signalKContext, signalKPath); break; case 'full': results = testFullSignalK(payloadInput); break; default: throw new Error('Unknown format'); } // Add source label to results results.sourceLabel = sourceLabel; displayTestResults(results); // Build SignalK delta const delta = { context: results.context, updates: [ { $source: sourceLabel, timestamp: new Date().toISOString(), values: results.paths.map(p => ({ path: p.path, value: p.value })) } ] }; // Send to server const response = await fetch('/plugins/signalk-mqtt-import/api/test-send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ delta: delta }) }); const data = await response.json(); if (data.success) { showNotification('Test Sent', `Successfully sent ${results.paths.length} path(s) to SignalK`, 'success', 3000); addLog(`Test payload sent to SignalK: ${results.paths.length} paths`); // Add success indicator to results resultsDiv.innerHTML += '<div style="margin-top: 10px; padding: 8px; background: #d4edda; border: 1px solid #28a745; border-radius: 4px; color: #155724;">โœ“ Successfully sent to SignalK server</div>'; } else { showNotification('Test Failed', data.error || 'Failed to send to SignalK', 'error', 5000); resultsDiv.innerHTML += '<div style="margin-top: 10px; padding: 8px; background: #f8d7da; border: 1px solid #dc3545; border-radius: 4px; color: #721c24;">โœ— Failed to send to SignalK</div>'; } } catch (error) { resultsDiv.innerHTML = `<div class="test-results error"><strong>Error:</strong> ${error.message}</div>`; showNotification('Test Error', error.message, 'error', 5000); } } </script> </body> </html>