signalk-mqtt-export
Version:
SignalK plugin to selectively export data to MQTT with webapp management interface
660 lines (611 loc) โข 24.1 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_export.png">
<title>SignalK MQTT Export 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: 10px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.status-card {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
text-align: center;
border: 1px solid #dee2e6;
}
.status-value {
font-size: 2em;
font-weight: bold;
color: #2196F3;
display: block;
}
.status-label {
color: #6c757d;
margin-top: 5px;
}
.status-connected {
color: #28a745;
}
.status-disconnected {
color: #dc3545;
}
.btn {
background: #2196F3;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
margin: 5px;
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: 20px;
}
.rules-table th,
.rules-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.rules-table th {
background-color: #f8f9fa;
font-weight: 600;
}
.rules-table tr:hover {
background-color: #f5f5f5;
}
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.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: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #2196F3;
}
input:checked + .slider:before {
transform: translateX(26px);
}
.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: 15px;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 12px;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
}
.tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
margin: 2px;
}
.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;
}
.info-box {
background: #e3f2fd;
border: 1px solid #2196F3;
border-radius: 6px;
padding: 15px;
margin-bottom: 20px;
}
.info-box h4 {
margin-top: 0;
color: #1976D2;
}
</style>
</head>
<body>
<div class="header">
<h1> <img src="mqtt_export.png" alt="MQTT Export" style="height: 80px; vertical-align: middle; margin-right: 10px; border-radius: 5px;">
SignalK MQTT Export Manager</h1>
<p>Export SignalK data to MQTT topics with flexible rules</p>
</div>
<!-- Info Box -->
<div class="info-box">
<h4>๐ค MQTT Export</h4>
<p>This plugin subscribes to SignalK data streams and exports them to MQTT topics. It works as the inverse of the MQTT Import plugin, allowing you to selectively choose which SignalK data to export and how to format it for MQTT.</p>
</div>
<!-- Status Overview -->
<div class="card">
<h3>๐ Status Overview</h3>
<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="activeSubscriptions" class="status-value">0</span>
<div class="status-label">Active Subscriptions</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>
<!-- Export Rules Management -->
<div class="card">
<h3>โ๏ธ Export Rules</h3>
<div>
<button class="btn btn-success" onclick="showAddRuleModal()">โ Add Rule</button>
<button class="btn" onclick="refreshRules()">๐ Refresh Rules</button>
<button class="btn btn-warning" onclick="saveRules()">๐พ Save Changes</button>
</div>
<div id="rulesContainer">
<table class="rules-table">
<thead>
<tr>
<th>Enabled</th>
<th>Name</th>
<th>Context</th>
<th>Path</th>
<th>Source</th>
<th>Exclude MMSIs</th>
<th>Send on Change</th>
<th>Period</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="rulesTableBody">
<!-- Rules will be populated here -->
</tbody>
</table>
</div>
</div>
<!-- Activity Log -->
<div class="card">
<h3>๐ Activity Log</h3>
<div id="activityLog" class="logs">
Activity logs will appear here...
</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 Export 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="ruleContext">Context:</label>
<select id="ruleContext" name="context" required>
<option value="vessels.self">vessels.self</option>
<option value="vessels.*">vessels.* (All vessels)</option>
<option value="vessels.urn:*">vessels.urn:* (All AIS vessels)</option>
<option value="meteo.*">meteo.* (All weather stations)</option>
</select>
</div>
<div class="form-group">
<label for="rulePath">SignalK Path:</label>
<input type="text" id="rulePath" name="path" required placeholder="e.g., navigation.position, *, electrical.batteries.*">
</div>
<div class="form-group">
<label for="ruleSource">Source Filter (Optional):</label>
<input type="text" id="ruleSource" name="source" placeholder="e.g., maiana.GP, NighthawkM5, leave empty for all">
</div>
<div class="form-group">
<label for="rulePeriod">Update Period (ms):</label>
<input type="number" id="rulePeriod" name="period" value="1000" min="100" max="60000">
</div>
<div class="form-group">
<label for="ruleQos">MQTT QoS:</label>
<select id="ruleQos" name="qos">
<option value="0">0 - At most once</option>
<option value="1">1 - At least once</option>
<option value="2">2 - Exactly once</option>
</select>
</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>
</select>
</div>
<div class="form-group">
<label for="ruleTopicTemplate">Topic Template (Optional):</label>
<input type="text" id="ruleTopicTemplate" name="topicTemplate" placeholder="e.g., marine/{context}/{path}">
</div>
<div class="form-group">
<label for="ruleExcludeMMSI">Exclude MMSIs (Optional):</label>
<input type="text" id="ruleExcludeMMSI" name="excludeMMSI" placeholder="e.g., 368396230, 123456789">
</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="ruleSendOnChange" name="sendOnChange" checked>
Send on Change Only
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="ruleRetain" name="retain">
MQTT Retain
</label>
</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;
// 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-export/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 rules status
const rulesResponse = await fetch('/plugins/signalk-mqtt-export/api/rules');
const rulesData = await rulesResponse.json();
if (rulesData.success) {
const activeCount = rulesData.rules.filter(r => r.enabled).length;
document.getElementById('activeRules').textContent = activeCount;
document.getElementById('activeSubscriptions').textContent = rulesData.activeSubscriptions;
document.getElementById('totalRules').textContent = rulesData.rules.length;
}
addLog('Status refreshed successfully');
} catch (error) {
addLog('Error refreshing status: ' + error.message);
}
}
// Refresh export rules
async function refreshRules() {
try {
const response = await fetch('/plugins/signalk-mqtt-export/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-context">${rule.context}</span></td>
<td><code>${rule.path}</code></td>
<td><span class="tag tag-source">${rule.source || 'All'}</span></td>
<td><span class="tag tag-source">${rule.excludeMMSI || 'None'}</span></td>
<td><span class="tag ${rule.sendOnChange ? 'tag-enabled' : 'tag-disabled'}">${rule.sendOnChange ? 'Yes' : 'No'}</span></td>
<td>${rule.period}ms</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
function toggleRule(index) {
currentRules[index].enabled = !currentRules[index].enabled;
addLog(`Rule "${currentRules[index].name}" ${currentRules[index].enabled ? 'enabled' : 'disabled'}`);
}
// Show add rule modal
function showAddRuleModal() {
editingRuleIndex = -1;
document.getElementById('modalTitle').textContent = 'Add Export Rule';
document.getElementById('ruleForm').reset();
document.getElementById('ruleEnabled').checked = true;
document.getElementById('ruleModal').style.display = 'block';
}
// Edit rule
function editRule(index) {
editingRuleIndex = index;
const rule = currentRules[index];
document.getElementById('modalTitle').textContent = 'Edit Export Rule';
document.getElementById('ruleName').value = rule.name;
document.getElementById('ruleContext').value = rule.context;
document.getElementById('rulePath').value = rule.path;
document.getElementById('ruleSource').value = rule.source || '';
document.getElementById('rulePeriod').value = rule.period;
document.getElementById('ruleQos').value = rule.qos;
document.getElementById('rulePayloadFormat').value = rule.payloadFormat;
document.getElementById('ruleTopicTemplate').value = rule.topicTemplate || '';
document.getElementById('ruleExcludeMMSI').value = rule.excludeMMSI || '';
document.getElementById('ruleEnabled').checked = rule.enabled;
document.getElementById('ruleSendOnChange').checked = rule.sendOnChange !== false; // Default to true
document.getElementById('ruleRetain').checked = rule.retain;
document.getElementById('ruleModal').style.display = 'block';
}
// Delete rule
function deleteRule(index) {
if (confirm(`Are you sure you want to delete rule "${currentRules[index].name}"?`)) {
currentRules.splice(index, 1);
displayRules();
addLog('Rule deleted');
}
}
// Close rule modal
function closeRuleModal() {
document.getElementById('ruleModal').style.display = 'none';
}
// Handle rule form submission
document.getElementById('ruleForm').addEventListener('submit', 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'),
context: formData.get('context'),
path: formData.get('path'),
source: formData.get('source'),
period: parseInt(formData.get('period')),
qos: parseInt(formData.get('qos')),
payloadFormat: formData.get('payloadFormat'),
topicTemplate: formData.get('topicTemplate'),
excludeMMSI: formData.get('excludeMMSI'),
enabled: document.getElementById('ruleEnabled').checked,
sendOnChange: document.getElementById('ruleSendOnChange').checked,
retain: document.getElementById('ruleRetain').checked
};
if (editingRuleIndex >= 0) {
currentRules[editingRuleIndex] = rule;
addLog(`Rule "${rule.name}" updated`);
} else {
currentRules.push(rule);
addLog(`Rule "${rule.name}" added`);
}
displayRules();
closeRuleModal();
});
// Save rules to server
async function saveRules() {
try {
const response = await fetch('/plugins/signalk-mqtt-export/api/rules', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ rules: currentRules })
});
const data = await response.json();
if (data.success) {
addLog('Rules saved successfully');
refreshStatus();
} else {
addLog('Error saving rules: ' + data.error);
}
} catch (error) {
addLog('Error saving rules: ' + error.message);
}
}
// Test MQTT connection
async function testMQTT() {
try {
const response = await fetch('/plugins/signalk-mqtt-export/api/test-mqtt', {
method: 'POST'
});
const data = await response.json();
if (data.success) {
addLog('MQTT test successful: ' + data.message);
} else {
addLog('MQTT test failed: ' + data.error);
}
} catch (error) {
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();
}
}
</script>
</body>
</html>