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
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()">×</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>