Complete IoT monitoring platform for Acuvim II power meters via ESP32. Firmware (Phases 1-7): - ESP32-WROVER-B (TTGO T-Call v1.4) with RS485 Modbus RTU - WiFi STA+AP concurrent mode with GSM/GPRS failover - Transport abstraction layer with 4 priority modes - MQTT protocol with 20 commands, LWT, QoS, exponential backoff - SD card offline buffering with JSONL rotation and non-blocking drain - OTA firmware updates with dual partition rollback protection - Watchdog timer, crash loop detection, Acuvim health monitoring - Captive portal provisioning with AP mode Console backend (Phase 8): - .NET 10 minimal API with PostgreSQL + EF Core - JWT authentication, SignalR real-time updates - MQTTnet 5.x bridge service with health monitoring - Device, telemetry, firmware, alert, group management - Rate limiting, security headers, Swagger/OpenAPI Frontend (Phase 9): - React 18 + TypeScript + Vite with Ant Design 5 - ECharts telemetry visualization, TanStack Query - SignalR live updates, device management UI - Dashboard, fleet management, firmware deployment Testing & Production (Phase 10): - 28 firmware unit tests (Modbus, JSON, config, version) - 23 xUnit backend tests (device, telemetry, command, alert) - Docker Compose with nginx, TLS MQTT, PostgreSQL - Production deployment, commissioning, and troubleshooting docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
451 lines
18 KiB
JavaScript
451 lines
18 KiB
JavaScript
// --- Tab navigation ---
|
|
document.querySelectorAll('.tab').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
|
|
document.querySelectorAll('.panel').forEach(function(p) { p.classList.remove('active'); });
|
|
btn.classList.add('active');
|
|
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
|
if (btn.dataset.tab === 'wifi') loadWifiConfig();
|
|
if (btn.dataset.tab === 'mqtt') loadMqttConfig();
|
|
if (btn.dataset.tab === 'sleep') loadSleepConfig();
|
|
if (btn.dataset.tab === 'gsm') loadGsmTab();
|
|
if (btn.dataset.tab === 'device') loadDeviceInfo();
|
|
});
|
|
});
|
|
|
|
// --- Toast ---
|
|
function toast(msg, ok) {
|
|
var el = document.getElementById('toast');
|
|
el.textContent = msg;
|
|
el.className = 'toast ' + (ok ? 'ok' : 'err');
|
|
setTimeout(function() { el.classList.add('hidden'); }, 3000);
|
|
}
|
|
|
|
// --- API helpers ---
|
|
function api(url, opts) {
|
|
return fetch(url, opts).then(function(r) { return r.json(); });
|
|
}
|
|
function postJson(url, data) {
|
|
return api(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
}
|
|
|
|
// --- Formatting ---
|
|
function fmtUptime(sec) {
|
|
var d = Math.floor(sec / 86400);
|
|
var h = Math.floor((sec % 86400) / 3600);
|
|
var m = Math.floor((sec % 3600) / 60);
|
|
var s = sec % 60;
|
|
var parts = [];
|
|
if (d > 0) parts.push(d + 'd');
|
|
if (h > 0) parts.push(h + 'h');
|
|
if (m > 0) parts.push(m + 'm');
|
|
parts.push(s + 's');
|
|
return parts.join(' ');
|
|
}
|
|
function fmtBytes(b) {
|
|
if (b >= 1048576) return (b / 1048576).toFixed(1) + ' MB';
|
|
if (b >= 1024) return (b / 1024).toFixed(0) + ' KB';
|
|
return b + ' B';
|
|
}
|
|
function setDot(id, on) {
|
|
var el = document.getElementById(id);
|
|
if (el) { el.className = 'dot ' + (on ? 'on' : 'off'); }
|
|
}
|
|
|
|
// --- Status Tab ---
|
|
function loadStatus() {
|
|
api('/api/status').then(function(d) {
|
|
document.getElementById('s-devid').textContent = d.device_id;
|
|
document.getElementById('s-fw').textContent = 'v' + d.firmware_version;
|
|
document.getElementById('s-uptime').textContent = fmtUptime(d.uptime_sec);
|
|
|
|
setDot('hdr-status', d.wifi_connected || d.gsm_connected);
|
|
setDot('s-wifi-dot', d.wifi_connected);
|
|
setDot('s-mqtt-dot', d.mqtt_connected);
|
|
setDot('s-gsm-dot', d.gsm_connected);
|
|
setDot('s-mb-dot', d.modbus_connected);
|
|
|
|
document.getElementById('s-wifi').textContent =
|
|
d.wifi_connected ? 'WiFi: ' + d.wifi_ssid + ' (' + d.wifi_rssi + ' dBm)' : 'WiFi: Disconnected';
|
|
document.getElementById('s-mqtt').textContent =
|
|
d.mqtt_connected ? 'MQTT: Connected' : 'MQTT: Disconnected';
|
|
document.getElementById('s-gsm').textContent =
|
|
d.gsm_connected ? 'GSM: Connected (' + d.gsm_signal + '%)' :
|
|
(d.gsm_available ? 'GSM: Disconnected' : 'GSM: Not available');
|
|
document.getElementById('s-modbus').textContent =
|
|
'Modbus: ' + d.modbus_success_count + ' ok / ' + d.modbus_error_count + ' err';
|
|
|
|
setDot('s-sd-dot', d.sd_available);
|
|
document.getElementById('s-sd').textContent =
|
|
d.sd_available ? 'SD: Available' : 'SD: Not available';
|
|
document.getElementById('s-sd-queued').textContent = d.sd_queued_records + ' records';
|
|
});
|
|
|
|
if (document.getElementById('s-sd-files')) {
|
|
api('/api/sd/status').then(function(d) {
|
|
if (d.available) {
|
|
document.getElementById('s-sd').textContent =
|
|
'SD: ' + fmtBytes(d.total_bytes) + ' (' +
|
|
(d.total_bytes > 0 ? Math.round(d.used_bytes * 100 / d.total_bytes) : 0) + '% used)';
|
|
document.getElementById('s-sd-files').textContent = d.file_count;
|
|
document.getElementById('s-sd-retention').textContent = d.retention_days + ' days';
|
|
} else {
|
|
document.getElementById('s-sd-files').textContent = '--';
|
|
document.getElementById('s-sd-retention').textContent = '--';
|
|
}
|
|
});
|
|
}
|
|
|
|
api('/api/telemetry/latest').then(function(d) {
|
|
var el = document.getElementById('s-readings');
|
|
if (!d.valid) {
|
|
el.innerHTML = '<p class="muted">No data yet</p>';
|
|
return;
|
|
}
|
|
var v = d.v, i = d.i, p = d.p;
|
|
el.innerHTML =
|
|
'<div class="readings-grid">' +
|
|
rd('Va', v.a, 'V') + rd('Vb', v.b, 'V') + rd('Vc', v.c, 'V') +
|
|
rd('Ia', i.a, 'A') + rd('Ib', i.b, 'A') + rd('Ic', i.c, 'A') +
|
|
rd('P', p.total, 'kW') + rd('Q', p.reactive, 'kVAR') + rd('S', p.apparent, 'kVA') +
|
|
rd('PF', p.pf, '') + rd('F', d.f, 'Hz') + rd('Import', d.e.imp_act, 'kWh') +
|
|
'</div>';
|
|
});
|
|
}
|
|
function rd(label, value, unit) {
|
|
return '<div class="rd"><div class="lbl">' + label + '</div><div class="val">' + value + ' ' + unit + '</div></div>';
|
|
}
|
|
|
|
// --- WiFi Tab ---
|
|
function loadWifiConfig() {
|
|
api('/api/wifi/config').then(function(d) {
|
|
document.getElementById('w-enabled').checked = d.enabled;
|
|
document.getElementById('w-ssid').value = d.ssid || '';
|
|
document.getElementById('w-current').textContent =
|
|
d.connected ? d.ssid + ' (' + d.ip + ')' : 'Not connected';
|
|
});
|
|
}
|
|
function scanWifi() {
|
|
var el = document.getElementById('w-networks');
|
|
el.innerHTML = '<p class="muted">Scanning...</p>';
|
|
doScan();
|
|
}
|
|
function doScan() {
|
|
api('/api/wifi/scan').then(function(d) {
|
|
if (d.scanning) {
|
|
setTimeout(doScan, 1500);
|
|
return;
|
|
}
|
|
var el = document.getElementById('w-networks');
|
|
if (!d.networks || d.networks.length === 0) {
|
|
el.innerHTML = '<p class="muted">No networks found</p>';
|
|
return;
|
|
}
|
|
var html = '';
|
|
d.networks.forEach(function(n) {
|
|
html += '<div class="net-item" onclick="selectNetwork(\'' +
|
|
n.ssid.replace(/'/g, "\\'") + '\')">' +
|
|
'<span class="ssid">' + n.ssid + '</span>' +
|
|
'<span class="meta">' + n.rssi + ' dBm · ' + n.encryption + '</span></div>';
|
|
});
|
|
el.innerHTML = html;
|
|
});
|
|
}
|
|
function selectNetwork(ssid) {
|
|
document.getElementById('w-ssid').value = ssid;
|
|
document.getElementById('w-pass').focus();
|
|
}
|
|
function saveWifi() {
|
|
postJson('/api/wifi/config', {
|
|
ssid: document.getElementById('w-ssid').value,
|
|
password: document.getElementById('w-pass').value,
|
|
enabled: document.getElementById('w-enabled').checked
|
|
}).then(function(d) {
|
|
toast(d.message, d.success);
|
|
});
|
|
}
|
|
|
|
// --- MQTT Tab ---
|
|
function loadMqttConfig() {
|
|
api('/api/mqtt/config').then(function(d) {
|
|
document.getElementById('m-broker').value = d.broker || '';
|
|
document.getElementById('m-port').value = d.port;
|
|
document.getElementById('m-user').value = d.username || '';
|
|
document.getElementById('m-prefix').value = d.topic_prefix || 'acuvim';
|
|
document.getElementById('m-tls').checked = d.tls;
|
|
document.getElementById('m-status').textContent = d.connected ? 'Connected' : 'Disconnected';
|
|
});
|
|
api('/api/console/config').then(function(d) {
|
|
document.getElementById('c-url').value = d.url || '';
|
|
});
|
|
}
|
|
function saveMqtt() {
|
|
postJson('/api/mqtt/config', {
|
|
broker: document.getElementById('m-broker').value,
|
|
port: parseInt(document.getElementById('m-port').value) || 1883,
|
|
username: document.getElementById('m-user').value,
|
|
password: document.getElementById('m-pass').value,
|
|
topic_prefix: document.getElementById('m-prefix').value,
|
|
tls: document.getElementById('m-tls').checked
|
|
}).then(function(d) {
|
|
toast(d.message, d.success);
|
|
});
|
|
}
|
|
function saveConsole() {
|
|
postJson('/api/console/config', {
|
|
url: document.getElementById('c-url').value
|
|
}).then(function(d) {
|
|
toast(d.message, d.success);
|
|
});
|
|
}
|
|
|
|
// --- Sleep Tab ---
|
|
function loadSleepConfig() {
|
|
api('/api/sleep/config').then(function(d) {
|
|
document.getElementById('sl-dur').value = d.sleep_duration_min;
|
|
document.getElementById('sl-wake').value = d.wake_duration_sec;
|
|
document.getElementById('sl-poll').value = d.poll_interval_sec;
|
|
document.getElementById('sl-hb').value = d.heartbeat_interval_sec;
|
|
});
|
|
}
|
|
function saveSleep() {
|
|
postJson('/api/sleep/config', {
|
|
sleep_duration_min: parseInt(document.getElementById('sl-dur').value) || 0,
|
|
wake_duration_sec: parseInt(document.getElementById('sl-wake').value) || 300
|
|
}).then(function(d) {
|
|
toast(d.message, d.success);
|
|
});
|
|
}
|
|
function savePolling() {
|
|
postJson('/api/sleep/config', {
|
|
poll_interval_sec: parseInt(document.getElementById('sl-poll').value) || 5,
|
|
heartbeat_interval_sec: parseInt(document.getElementById('sl-hb').value) || 60
|
|
}).then(function(d) {
|
|
toast(d.message, d.success);
|
|
});
|
|
}
|
|
|
|
// --- Device Tab ---
|
|
function loadDeviceInfo() {
|
|
api('/api/device/info').then(function(d) {
|
|
document.getElementById('d-id').textContent = d.device_id;
|
|
document.getElementById('d-fw').textContent = 'v' + d.firmware_version;
|
|
document.getElementById('d-hw').textContent = d.hardware;
|
|
document.getElementById('d-mac').textContent = d.mac_address;
|
|
document.getElementById('d-heap').textContent = fmtBytes(d.free_heap);
|
|
document.getElementById('d-flash').textContent = fmtBytes(d.flash_size);
|
|
document.getElementById('d-psram').textContent = fmtBytes(d.psram_size);
|
|
});
|
|
api('/api/modbus/config').then(function(d) {
|
|
document.getElementById('mb-addr').value = d.slave_address;
|
|
document.getElementById('mb-baud').value = d.baud_rate;
|
|
});
|
|
loadOtaStatus();
|
|
}
|
|
function saveModbus() {
|
|
postJson('/api/modbus/config', {
|
|
slave_address: parseInt(document.getElementById('mb-addr').value) || 1,
|
|
baud_rate: parseInt(document.getElementById('mb-baud').value) || 9600
|
|
}).then(function(d) {
|
|
toast(d.message, d.success);
|
|
});
|
|
}
|
|
function saveGsm() {
|
|
postJson('/api/gsm/config', {
|
|
enabled: document.getElementById('g-enabled').checked,
|
|
apn: document.getElementById('g-apn').value,
|
|
username: document.getElementById('g-user').value,
|
|
password: document.getElementById('g-pass').value
|
|
}).then(function(d) {
|
|
toast(d.message, d.success);
|
|
});
|
|
}
|
|
|
|
// --- GSM Tab ---
|
|
function loadGsmTab() {
|
|
api('/api/gsm/status').then(function(d) {
|
|
document.getElementById('g-modem').textContent = d.modem_available ? 'Detected' : 'Not found';
|
|
document.getElementById('g-sim').textContent = d.sim_inserted ? 'Inserted' : 'Not detected';
|
|
document.getElementById('g-status').textContent = d.connected ? 'Connected' : 'Disconnected';
|
|
setDot('g-status-dot', d.connected);
|
|
document.getElementById('g-operator').textContent = d.operator || '--';
|
|
document.getElementById('g-signal').textContent =
|
|
d.signal_percent > 0 ? d.signal_percent + '% (CSQ: ' + d.signal_quality + ')' : '--';
|
|
document.getElementById('g-imei').textContent = d.imei || '--';
|
|
document.getElementById('g-iccid').textContent = d.iccid || '--';
|
|
});
|
|
api('/api/gsm/config').then(function(d) {
|
|
document.getElementById('g-enabled').checked = d.enabled;
|
|
document.getElementById('g-apn').value = d.apn || '';
|
|
document.getElementById('g-user').value = d.username || '';
|
|
});
|
|
api('/api/transport/config').then(function(d) {
|
|
var radios = document.querySelectorAll('input[name="t-prio"]');
|
|
radios.forEach(function(r) { r.checked = (parseInt(r.value) === d.priority); });
|
|
document.getElementById('g-poll').value = d.gsm_poll_interval_sec;
|
|
document.getElementById('g-batch').checked = d.gsm_batch_enabled;
|
|
document.getElementById('g-batchsize').value = d.gsm_batch_size;
|
|
});
|
|
}
|
|
function saveTransport() {
|
|
var prio = 0;
|
|
document.querySelectorAll('input[name="t-prio"]').forEach(function(r) {
|
|
if (r.checked) prio = parseInt(r.value);
|
|
});
|
|
postJson('/api/transport/config', {
|
|
priority: prio,
|
|
gsm_poll_interval_sec: parseInt(document.getElementById('g-poll').value) || 30,
|
|
gsm_batch_enabled: document.getElementById('g-batch').checked,
|
|
gsm_batch_size: parseInt(document.getElementById('g-batchsize').value) || 6
|
|
}).then(function(d) {
|
|
toast(d.message, d.success);
|
|
});
|
|
}
|
|
// --- OTA ---
|
|
function loadOtaStatus() {
|
|
api('/api/ota/status').then(function(d) {
|
|
document.getElementById('ota-ver').textContent = 'v' + d.firmware_version;
|
|
document.getElementById('ota-part').textContent = d.running_partition;
|
|
var statusNames = ['Idle', 'Checking...', 'Downloading...', 'Installing...', 'Success', 'Failed'];
|
|
var statusText = statusNames[d.status] || 'Unknown';
|
|
if (d.progress > 0 && d.status >= 2 && d.status <= 3) {
|
|
statusText += ' (' + d.progress + '%)';
|
|
}
|
|
if (d.message && d.status === 5) {
|
|
statusText += ': ' + d.message;
|
|
}
|
|
document.getElementById('ota-status').textContent = statusText;
|
|
|
|
var valEl = document.getElementById('ota-validation');
|
|
valEl.style.display = d.needs_validation ? 'block' : 'none';
|
|
|
|
if (d.update_available && d.available) {
|
|
var infoEl = document.getElementById('ota-update-info');
|
|
infoEl.style.display = 'block';
|
|
document.getElementById('ota-avail-ver').textContent = 'v' + d.available.version;
|
|
document.getElementById('ota-avail-size').textContent = fmtBytes(d.available.size);
|
|
document.getElementById('ota-avail-notes').textContent = d.available.release_notes || '';
|
|
}
|
|
});
|
|
api('/api/ota/config').then(function(d) {
|
|
document.getElementById('ota-auto').value = d.auto_update;
|
|
document.getElementById('ota-interval').value = d.check_interval_hours;
|
|
});
|
|
}
|
|
function checkOtaUpdate() {
|
|
api('/api/ota/check', { method: 'POST' }).then(function(d) {
|
|
toast(d.message, d.success);
|
|
if (d.update_available) {
|
|
var infoEl = document.getElementById('ota-update-info');
|
|
infoEl.style.display = 'block';
|
|
document.getElementById('ota-avail-ver').textContent = 'v' + d.version;
|
|
document.getElementById('ota-avail-size').textContent = fmtBytes(d.size);
|
|
document.getElementById('ota-avail-notes').textContent = d.release_notes || '';
|
|
}
|
|
});
|
|
}
|
|
function installOtaAvailable() {
|
|
if (!confirm('Install the available firmware update? Device will reboot.')) return;
|
|
api('/api/ota/status').then(function(d) {
|
|
if (d.update_available && d.available) {
|
|
postJson('/api/ota/install', { url: d.available ? d.available.url || '' : '' }).then(function(r) {
|
|
toast(r.message, r.success);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
function saveOtaConfig() {
|
|
postJson('/api/ota/config', {
|
|
check_interval_hours: parseInt(document.getElementById('ota-interval').value) || 6,
|
|
auto_update: parseInt(document.getElementById('ota-auto').value) || 1
|
|
}).then(function(d) {
|
|
toast(d.message, d.success);
|
|
});
|
|
}
|
|
function markOtaValid() {
|
|
postJson('/api/ota/install', { mark_valid: true }).then(function() {
|
|
toast('Firmware marked as valid', true);
|
|
loadOtaStatus();
|
|
});
|
|
}
|
|
function rollbackOta() {
|
|
if (!confirm('Rollback to previous firmware? Device will reboot.')) return;
|
|
api('/api/ota/rollback', { method: 'POST' }).then(function(d) {
|
|
toast(d.message, d.success);
|
|
});
|
|
}
|
|
function uploadFirmware() {
|
|
var fileEl = document.getElementById('ota-file');
|
|
if (!fileEl.files.length) {
|
|
toast('Select a firmware file', false);
|
|
return;
|
|
}
|
|
if (!confirm('Upload and install firmware? Device will reboot.')) return;
|
|
|
|
var formData = new FormData();
|
|
formData.append('firmware', fileEl.files[0]);
|
|
|
|
var progEl = document.getElementById('ota-progress');
|
|
var barEl = document.getElementById('ota-progress-bar');
|
|
var textEl = document.getElementById('ota-progress-text');
|
|
progEl.style.display = 'block';
|
|
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open('POST', '/api/ota/upload');
|
|
xhr.upload.addEventListener('progress', function(e) {
|
|
if (e.lengthComputable) {
|
|
var pct = Math.round(e.loaded * 100 / e.total);
|
|
barEl.style.width = pct + '%';
|
|
textEl.textContent = pct + '%';
|
|
}
|
|
});
|
|
xhr.onload = function() {
|
|
if (xhr.status === 200) {
|
|
toast('Upload complete. Rebooting...', true);
|
|
} else {
|
|
toast('Upload failed', false);
|
|
progEl.style.display = 'none';
|
|
}
|
|
};
|
|
xhr.onerror = function() {
|
|
toast('Upload error', false);
|
|
progEl.style.display = 'none';
|
|
};
|
|
xhr.send(formData);
|
|
}
|
|
|
|
// --- SD Card ---
|
|
function drainSd() {
|
|
postJson('/api/sd/drain', {}).then(function(d) {
|
|
toast(d.message, d.success);
|
|
});
|
|
}
|
|
function cleanupSd() {
|
|
if (!confirm('Delete old buffered files?')) return;
|
|
postJson('/api/sd/cleanup', {}).then(function(d) {
|
|
toast(d.message, d.success);
|
|
});
|
|
}
|
|
|
|
function restartDevice() {
|
|
if (!confirm('Restart the device?')) return;
|
|
postJson('/api/device/restart', {}).then(function(d) {
|
|
toast(d.message, d.success);
|
|
});
|
|
}
|
|
function factoryReset() {
|
|
if (!confirm('Factory reset will erase ALL settings. Continue?')) return;
|
|
if (!confirm('Are you sure? This cannot be undone.')) return;
|
|
postJson('/api/device/factory-reset', {}).then(function(d) {
|
|
toast(d.message, d.success);
|
|
});
|
|
}
|
|
|
|
// --- Auto-refresh status ---
|
|
loadStatus();
|
|
setInterval(loadStatus, 5000);
|