Tau.Acuvim/firmware/src/web_server.cpp
Renier Forster 84a0668c54 Initial commit: Tau Acuvim IoT monitoring system
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>
2026-05-16 19:05:32 +02:00

881 lines
28 KiB
C++

#include "web_server.h"
#include <ArduinoJson.h>
#include <LittleFS.h>
#include <Update.h>
WebPortal::WebPortal() : _server(80) {
memset(&_lastData, 0, sizeof(AcuvimData));
}
void WebPortal::begin(ConfigManager& config, WifiManager& wifi,
MqttClient& mqtt, AcuvimReader& acuvim,
GsmManager& gsm, TransportManager& transport,
SdManager& sd, OtaManager& ota) {
_config = &config;
_wifi = &wifi;
_mqtt = &mqtt;
_acuvim = &acuvim;
_gsm = &gsm;
_transport = &transport;
_sd = &sd;
_ota = &ota;
if (!LittleFS.begin(true)) {
Serial.println("[Web] LittleFS mount failed");
} else {
Serial.println("[Web] LittleFS mounted");
}
setupRoutes();
setupCaptivePortal();
_server.begin();
Serial.println("[Web] Server started on port 80");
}
void WebPortal::loop() {
_dns.processNextRequest();
}
void WebPortal::stop() {
_server.end();
_dns.stop();
}
// --- Helpers ---
void WebPortal::sendJsonResponse(AsyncWebServerRequest* req, int code, const String& json) {
req->send(code, "application/json", json);
}
void WebPortal::sendSuccess(AsyncWebServerRequest* req, const char* message) {
JsonDocument doc;
doc["success"] = true;
doc["message"] = message;
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::sendError(AsyncWebServerRequest* req, int code, const char* message) {
JsonDocument doc;
doc["success"] = false;
doc["message"] = message;
String out;
serializeJson(doc, out);
sendJsonResponse(req, code, out);
}
// --- Captive Portal ---
void WebPortal::setupCaptivePortal() {
_dns.start(53, "*", WiFi.softAPIP());
_server.onNotFound([this](AsyncWebServerRequest* req) {
if (req->host() != WiFi.softAPIP().toString() &&
req->host() != WiFi.localIP().toString()) {
req->redirect("http://" + WiFi.softAPIP().toString() + "/");
return;
}
if (LittleFS.exists(req->url())) {
req->send(LittleFS, req->url());
} else {
req->redirect("/");
}
});
}
// --- Route Setup ---
void WebPortal::setupRoutes() {
_server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");
// --- Status / Info ---
_server.on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* req) {
handleStatus(req);
});
_server.on("/api/device/info", HTTP_GET, [this](AsyncWebServerRequest* req) {
handleDeviceInfo(req);
});
_server.on("/api/telemetry/latest", HTTP_GET, [this](AsyncWebServerRequest* req) {
handleLatestTelemetry(req);
});
// --- WiFi ---
_server.on("/api/wifi/scan", HTTP_GET, [this](AsyncWebServerRequest* req) {
handleWifiScan(req);
});
_server.on("/api/wifi/config", HTTP_GET, [this](AsyncWebServerRequest* req) {
handleGetWifiConfig(req);
});
_server.on("/api/wifi/config", HTTP_POST,
[](AsyncWebServerRequest* req) {},
NULL,
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
if (index + len == total) handlePostWifiConfig(req, data, len);
}
);
_server.on("/api/wifi/disconnect", HTTP_POST, [this](AsyncWebServerRequest* req) {
handleWifiDisconnect(req);
});
// --- MQTT ---
_server.on("/api/mqtt/config", HTTP_GET, [this](AsyncWebServerRequest* req) {
handleGetMqttConfig(req);
});
_server.on("/api/mqtt/config", HTTP_POST,
[](AsyncWebServerRequest* req) {},
NULL,
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
if (index + len == total) handlePostMqttConfig(req, data, len);
}
);
// --- Sleep ---
_server.on("/api/sleep/config", HTTP_GET, [this](AsyncWebServerRequest* req) {
handleGetSleepConfig(req);
});
_server.on("/api/sleep/config", HTTP_POST,
[](AsyncWebServerRequest* req) {},
NULL,
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
if (index + len == total) handlePostSleepConfig(req, data, len);
}
);
// --- Console ---
_server.on("/api/console/config", HTTP_GET, [this](AsyncWebServerRequest* req) {
handleGetConsoleConfig(req);
});
_server.on("/api/console/config", HTTP_POST,
[](AsyncWebServerRequest* req) {},
NULL,
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
if (index + len == total) handlePostConsoleConfig(req, data, len);
}
);
// --- Modbus ---
_server.on("/api/modbus/config", HTTP_GET, [this](AsyncWebServerRequest* req) {
handleGetModbusConfig(req);
});
_server.on("/api/modbus/config", HTTP_POST,
[](AsyncWebServerRequest* req) {},
NULL,
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
if (index + len == total) handlePostModbusConfig(req, data, len);
}
);
// --- GSM ---
_server.on("/api/gsm/config", HTTP_GET, [this](AsyncWebServerRequest* req) {
handleGetGsmConfig(req);
});
_server.on("/api/gsm/config", HTTP_POST,
[](AsyncWebServerRequest* req) {},
NULL,
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
if (index + len == total) handlePostGsmConfig(req, data, len);
}
);
_server.on("/api/gsm/status", HTTP_GET, [this](AsyncWebServerRequest* req) {
handleGetGsmStatus(req);
});
// --- Transport ---
_server.on("/api/transport/config", HTTP_GET, [this](AsyncWebServerRequest* req) {
handleGetTransportConfig(req);
});
_server.on("/api/transport/config", HTTP_POST,
[](AsyncWebServerRequest* req) {},
NULL,
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
if (index + len == total) handlePostTransportConfig(req, data, len);
}
);
// --- SD Card ---
_server.on("/api/sd/status", HTTP_GET, [this](AsyncWebServerRequest* req) {
handleGetSdStatus(req);
});
_server.on("/api/sd/drain", HTTP_POST, [this](AsyncWebServerRequest* req) {
handlePostSdDrain(req);
});
_server.on("/api/sd/cleanup", HTTP_POST, [this](AsyncWebServerRequest* req) {
handlePostSdCleanup(req);
});
// --- OTA ---
_server.on("/api/ota/status", HTTP_GET, [this](AsyncWebServerRequest* req) {
handleGetOtaStatus(req);
});
_server.on("/api/ota/check", HTTP_POST, [this](AsyncWebServerRequest* req) {
handlePostOtaCheck(req);
});
_server.on("/api/ota/install", HTTP_POST,
[](AsyncWebServerRequest* req) {},
NULL,
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
if (index + len == total) handlePostOtaInstall(req, data, len);
}
);
_server.on("/api/ota/upload", HTTP_POST,
[this](AsyncWebServerRequest* req) {
if (Update.hasError()) {
sendError(req, 500, "Upload failed");
} else {
sendSuccess(req, "Upload complete. Rebooting...");
delay(1000);
ESP.restart();
}
},
[this](AsyncWebServerRequest* req, const String& filename, size_t index,
uint8_t* data, size_t len, bool final) {
handleOtaUpload(req, filename, index, data, len, final);
}
);
_server.on("/api/ota/rollback", HTTP_POST, [this](AsyncWebServerRequest* req) {
handlePostOtaRollback(req);
});
_server.on("/api/ota/config", HTTP_GET, [this](AsyncWebServerRequest* req) {
handleGetOtaConfig(req);
});
_server.on("/api/ota/config", HTTP_POST,
[](AsyncWebServerRequest* req) {},
NULL,
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
if (index + len == total) handlePostOtaConfig(req, data, len);
}
);
// --- Device Management ---
_server.on("/api/device/restart", HTTP_POST, [this](AsyncWebServerRequest* req) {
handleRestart(req);
});
_server.on("/api/device/factory-reset", HTTP_POST, [this](AsyncWebServerRequest* req) {
handleFactoryReset(req);
});
}
// --- Handler Implementations ---
void WebPortal::handleStatus(AsyncWebServerRequest* req) {
JsonDocument doc;
DeviceConfig& cfg = _config->get();
doc["device_id"] = cfg.device_id;
doc["firmware_version"] = cfg.firmware_version;
doc["uptime_sec"] = millis() / 1000;
doc["free_heap"] = ESP.getFreeHeap();
doc["wifi_connected"] = _wifi->isConnected();
doc["wifi_ssid"] = _wifi->getSSID();
doc["wifi_rssi"] = _wifi->getRSSI();
doc["wifi_ip"] = _wifi->getIPAddress();
doc["mqtt_connected"] = _mqtt->isConnected();
doc["mqtt_broker"] = cfg.mqtt_broker;
doc["gsm_available"] = _gsm->isModemAvailable();
doc["gsm_connected"] = _gsm->isConnected();
doc["gsm_signal"] = _gsm->getSignalPercent();
doc["modbus_connected"] = _lastData.valid;
doc["modbus_success_count"] = _acuvim->getSuccessCount();
doc["modbus_error_count"] = _acuvim->getErrorCount();
doc["sd_available"] = _sd->isAvailable();
doc["sd_queued_records"] = _sd->getQueuedCount();
doc["connection_type"] = TransportManager::transportTypeName(_transport->getActiveTransport());
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::handleDeviceInfo(AsyncWebServerRequest* req) {
JsonDocument doc;
DeviceConfig& cfg = _config->get();
doc["device_id"] = cfg.device_id;
doc["firmware_version"] = cfg.firmware_version;
doc["hardware"] = "TTGO T-Call v1.4";
doc["mac_address"] = _wifi->getMACAddress();
doc["chip_model"] = ESP.getChipModel();
doc["chip_revision"] = ESP.getChipRevision();
doc["flash_size"] = ESP.getFlashChipSize();
doc["psram_size"] = ESP.getPsramSize();
doc["free_heap"] = ESP.getFreeHeap();
doc["min_free_heap"] = ESP.getMinFreeHeap();
doc["sdk_version"] = ESP.getSdkVersion();
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::handleLatestTelemetry(AsyncWebServerRequest* req) {
JsonDocument doc;
const AcuvimData& d = _lastData;
doc["valid"] = d.valid;
doc["timestamp"] = d.timestamp;
if (d.valid) {
JsonObject v = doc["v"].to<JsonObject>();
v["a"] = serialized(String(d.voltage_a, 1));
v["b"] = serialized(String(d.voltage_b, 1));
v["c"] = serialized(String(d.voltage_c, 1));
v["ab"] = serialized(String(d.voltage_ab, 1));
v["bc"] = serialized(String(d.voltage_bc, 1));
v["ca"] = serialized(String(d.voltage_ca, 1));
JsonObject i = doc["i"].to<JsonObject>();
i["a"] = serialized(String(d.current_a, 2));
i["b"] = serialized(String(d.current_b, 2));
i["c"] = serialized(String(d.current_c, 2));
JsonObject p = doc["p"].to<JsonObject>();
p["total"] = serialized(String(d.active_power, 3));
p["a"] = serialized(String(d.power_a, 3));
p["b"] = serialized(String(d.power_b, 3));
p["c"] = serialized(String(d.power_c, 3));
p["reactive"] = serialized(String(d.reactive_power, 3));
p["apparent"] = serialized(String(d.apparent_power, 3));
p["pf"] = serialized(String(d.power_factor, 3));
doc["f"] = serialized(String(d.frequency, 2));
JsonObject e = doc["e"].to<JsonObject>();
e["imp_act"] = serialized(String(d.import_active_energy, 1));
e["exp_act"] = serialized(String(d.export_active_energy, 1));
e["imp_react"] = serialized(String(d.import_reactive_energy, 1));
e["exp_react"] = serialized(String(d.export_reactive_energy, 1));
JsonObject dm = doc["d"].to<JsonObject>();
dm["act"] = serialized(String(d.active_demand, 3));
dm["max_act"] = serialized(String(d.max_active_demand, 3));
dm["react"] = serialized(String(d.reactive_demand, 3));
JsonObject thd = doc["thd"].to<JsonObject>();
thd["va"] = serialized(String(d.thd_voltage_a, 2));
thd["vb"] = serialized(String(d.thd_voltage_b, 2));
thd["vc"] = serialized(String(d.thd_voltage_c, 2));
thd["ia"] = serialized(String(d.thd_current_a, 2));
thd["ib"] = serialized(String(d.thd_current_b, 2));
thd["ic"] = serialized(String(d.thd_current_c, 2));
}
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::handleWifiScan(AsyncWebServerRequest* req) {
if (!_wifi->isScanComplete()) {
_wifi->startScan();
sendJsonResponse(req, 200, "{\"scanning\":true}");
return;
}
sendJsonResponse(req, 200, _wifi->getScanResultsJson());
}
void WebPortal::handleGetWifiConfig(AsyncWebServerRequest* req) {
JsonDocument doc;
DeviceConfig& cfg = _config->get();
doc["ssid"] = cfg.wifi_ssid;
doc["password"] = "********";
doc["enabled"] = cfg.wifi_enabled;
doc["connected"] = _wifi->isConnected();
doc["ip"] = _wifi->getIPAddress();
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::handlePostWifiConfig(AsyncWebServerRequest* req, uint8_t* data, size_t len) {
JsonDocument doc;
if (deserializeJson(doc, data, len)) {
sendError(req, 400, "Invalid JSON");
return;
}
DeviceConfig& cfg = _config->get();
bool changed = false;
if (doc.containsKey("ssid")) {
strlcpy(cfg.wifi_ssid, doc["ssid"] | "", sizeof(cfg.wifi_ssid));
changed = true;
}
if (doc.containsKey("password")) {
const char* pass = doc["password"] | "";
if (strcmp(pass, "********") != 0) {
strlcpy(cfg.wifi_password, pass, sizeof(cfg.wifi_password));
changed = true;
}
}
if (doc.containsKey("enabled")) {
cfg.wifi_enabled = doc["enabled"] | true;
changed = true;
}
if (changed) {
_config->save();
if (cfg.wifi_enabled && cfg.wifi_ssid[0] != '\0') {
_wifi->connectTo(cfg.wifi_ssid, cfg.wifi_password);
}
}
sendSuccess(req, "WiFi configuration saved. Connecting...");
}
void WebPortal::handleWifiDisconnect(AsyncWebServerRequest* req) {
_wifi->disconnect();
sendSuccess(req, "WiFi disconnected");
}
void WebPortal::handleGetMqttConfig(AsyncWebServerRequest* req) {
JsonDocument doc;
DeviceConfig& cfg = _config->get();
doc["broker"] = cfg.mqtt_broker;
doc["port"] = cfg.mqtt_port;
doc["username"] = cfg.mqtt_username;
doc["password"] = "********";
doc["topic_prefix"] = cfg.mqtt_topic_prefix;
doc["tls"] = cfg.mqtt_tls;
doc["connected"] = _mqtt->isConnected();
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::handlePostMqttConfig(AsyncWebServerRequest* req, uint8_t* data, size_t len) {
JsonDocument doc;
if (deserializeJson(doc, data, len)) {
sendError(req, 400, "Invalid JSON");
return;
}
DeviceConfig& cfg = _config->get();
if (doc.containsKey("broker"))
strlcpy(cfg.mqtt_broker, doc["broker"] | "", sizeof(cfg.mqtt_broker));
if (doc.containsKey("port"))
cfg.mqtt_port = doc["port"] | 1883;
if (doc.containsKey("username"))
strlcpy(cfg.mqtt_username, doc["username"] | "", sizeof(cfg.mqtt_username));
if (doc.containsKey("password")) {
const char* pass = doc["password"] | "";
if (strcmp(pass, "********") != 0)
strlcpy(cfg.mqtt_password, pass, sizeof(cfg.mqtt_password));
}
if (doc.containsKey("topic_prefix"))
strlcpy(cfg.mqtt_topic_prefix, doc["topic_prefix"] | "acuvim", sizeof(cfg.mqtt_topic_prefix));
if (doc.containsKey("tls"))
cfg.mqtt_tls = doc["tls"] | false;
_config->save();
_mqtt->disconnect();
_mqtt->setConfig(cfg);
if (_wifi->isConnected()) {
_mqtt->connect();
}
sendSuccess(req, "MQTT configuration saved. Reconnecting...");
}
void WebPortal::handleGetSleepConfig(AsyncWebServerRequest* req) {
JsonDocument doc;
DeviceConfig& cfg = _config->get();
doc["sleep_enabled"] = cfg.sleep_duration_min > 0;
doc["sleep_duration_min"] = cfg.sleep_duration_min;
doc["wake_duration_sec"] = cfg.wake_duration_sec;
doc["poll_interval_sec"] = cfg.poll_interval_sec;
doc["heartbeat_interval_sec"] = cfg.heartbeat_interval_sec;
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::handlePostSleepConfig(AsyncWebServerRequest* req, uint8_t* data, size_t len) {
JsonDocument doc;
if (deserializeJson(doc, data, len)) {
sendError(req, 400, "Invalid JSON");
return;
}
DeviceConfig& cfg = _config->get();
if (doc.containsKey("sleep_duration_min"))
cfg.sleep_duration_min = doc["sleep_duration_min"] | 0;
if (doc.containsKey("wake_duration_sec"))
cfg.wake_duration_sec = doc["wake_duration_sec"] | 300;
if (doc.containsKey("poll_interval_sec")) {
uint16_t val = doc["poll_interval_sec"] | 5;
if (val >= 1 && val <= 3600) cfg.poll_interval_sec = val;
}
if (doc.containsKey("heartbeat_interval_sec")) {
uint16_t val = doc["heartbeat_interval_sec"] | 60;
if (val >= 10 && val <= 3600) cfg.heartbeat_interval_sec = val;
}
_config->save();
sendSuccess(req, "Sleep configuration saved.");
}
void WebPortal::handleGetConsoleConfig(AsyncWebServerRequest* req) {
JsonDocument doc;
doc["url"] = _config->get().console_url;
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::handlePostConsoleConfig(AsyncWebServerRequest* req, uint8_t* data, size_t len) {
JsonDocument doc;
if (deserializeJson(doc, data, len)) {
sendError(req, 400, "Invalid JSON");
return;
}
if (doc.containsKey("url")) {
strlcpy(_config->get().console_url, doc["url"] | "", sizeof(_config->get().console_url));
_config->save();
}
sendSuccess(req, "Console URL saved");
}
void WebPortal::handleGetModbusConfig(AsyncWebServerRequest* req) {
JsonDocument doc;
DeviceConfig& cfg = _config->get();
doc["slave_address"] = cfg.modbus_slave_addr;
doc["baud_rate"] = cfg.modbus_baud_rate;
doc["poll_interval_sec"] = cfg.poll_interval_sec;
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::handlePostModbusConfig(AsyncWebServerRequest* req, uint8_t* data, size_t len) {
JsonDocument doc;
if (deserializeJson(doc, data, len)) {
sendError(req, 400, "Invalid JSON");
return;
}
DeviceConfig& cfg = _config->get();
if (doc.containsKey("slave_address")) {
uint8_t addr = doc["slave_address"] | 1;
if (addr >= 1 && addr <= 247) cfg.modbus_slave_addr = addr;
}
if (doc.containsKey("baud_rate"))
cfg.modbus_baud_rate = doc["baud_rate"] | 9600;
if (doc.containsKey("poll_interval_sec")) {
uint16_t val = doc["poll_interval_sec"] | 5;
if (val >= 1 && val <= 3600) cfg.poll_interval_sec = val;
}
_config->save();
_acuvim->begin(cfg.modbus_slave_addr, cfg.modbus_baud_rate);
sendSuccess(req, "Modbus configuration saved. Reinitializing...");
}
void WebPortal::handleGetGsmConfig(AsyncWebServerRequest* req) {
JsonDocument doc;
DeviceConfig& cfg = _config->get();
doc["apn"] = cfg.gsm_apn;
doc["username"] = cfg.gsm_user;
doc["password"] = "********";
doc["enabled"] = cfg.gsm_enabled;
doc["connected"] = _gsm->isConnected();
doc["signal_strength"] = _gsm->getSignalPercent();
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::handlePostGsmConfig(AsyncWebServerRequest* req, uint8_t* data, size_t len) {
JsonDocument doc;
if (deserializeJson(doc, data, len)) {
sendError(req, 400, "Invalid JSON");
return;
}
DeviceConfig& cfg = _config->get();
if (doc.containsKey("apn"))
strlcpy(cfg.gsm_apn, doc["apn"] | "", sizeof(cfg.gsm_apn));
if (doc.containsKey("username"))
strlcpy(cfg.gsm_user, doc["username"] | "", sizeof(cfg.gsm_user));
if (doc.containsKey("password")) {
const char* pass = doc["password"] | "";
if (strcmp(pass, "********") != 0)
strlcpy(cfg.gsm_pass, pass, sizeof(cfg.gsm_pass));
}
if (doc.containsKey("enabled"))
cfg.gsm_enabled = doc["enabled"] | false;
_config->save();
sendSuccess(req, "GSM configuration saved");
}
void WebPortal::handleGetSdStatus(AsyncWebServerRequest* req) {
JsonDocument doc;
doc["available"] = _sd->isAvailable();
doc["total_bytes"] = (double)_sd->getTotalSpace();
doc["used_bytes"] = (double)_sd->getUsedSpace();
doc["free_bytes"] = (double)_sd->getFreeSpace();
doc["queued_records"] = _sd->getQueuedCount();
doc["file_count"] = _sd->getFileCount();
doc["retention_days"] = _sd->getRetentionDays();
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::handlePostSdDrain(AsyncWebServerRequest* req) {
if (!_sd->isAvailable()) {
sendError(req, 400, "No SD card available");
return;
}
uint32_t queued = _sd->getQueuedCount();
String msg = "Drain started. " + String(queued) + " records queued.";
sendSuccess(req, msg.c_str());
}
void WebPortal::handlePostSdCleanup(AsyncWebServerRequest* req) {
if (!_sd->isAvailable()) {
sendError(req, 400, "No SD card available");
return;
}
uint32_t deleted = _sd->cleanup();
String msg = "Cleanup complete. " + String(deleted) + " files deleted.";
sendSuccess(req, msg.c_str());
}
void WebPortal::handleGetGsmStatus(AsyncWebServerRequest* req) {
JsonDocument doc;
doc["modem_available"] = _gsm->isModemAvailable();
doc["sim_inserted"] = _gsm->isSimInserted();
doc["connected"] = _gsm->isConnected();
doc["signal_quality"] = _gsm->getSignalQuality();
doc["signal_percent"] = _gsm->getSignalPercent();
doc["operator"] = _gsm->getOperator();
doc["imei"] = _gsm->getIMEI();
doc["iccid"] = _gsm->getICCID();
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::handleGetTransportConfig(AsyncWebServerRequest* req) {
JsonDocument doc;
DeviceConfig& cfg = _config->get();
doc["priority"] = cfg.transport_priority;
doc["active"] = TransportManager::transportTypeName(_transport->getActiveTransport());
doc["gsm_poll_interval_sec"] = cfg.gsm_poll_interval_sec;
doc["gsm_batch_enabled"] = cfg.gsm_batch_enabled;
doc["gsm_batch_size"] = cfg.gsm_batch_size;
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::handlePostTransportConfig(AsyncWebServerRequest* req, uint8_t* data, size_t len) {
JsonDocument doc;
if (deserializeJson(doc, data, len)) {
sendError(req, 400, "Invalid JSON");
return;
}
DeviceConfig& cfg = _config->get();
if (doc.containsKey("priority")) {
uint8_t prio = doc["priority"] | 0;
if (prio <= 3) {
_transport->setPriority((TransportPriority)prio);
cfg.transport_priority = prio;
}
}
if (doc.containsKey("gsm_poll_interval_sec")) {
uint16_t val = doc["gsm_poll_interval_sec"] | 30;
if (val >= 5 && val <= 3600) cfg.gsm_poll_interval_sec = val;
}
if (doc.containsKey("gsm_batch_enabled"))
cfg.gsm_batch_enabled = doc["gsm_batch_enabled"] | false;
if (doc.containsKey("gsm_batch_size")) {
uint8_t val = doc["gsm_batch_size"] | 6;
if (val >= 2 && val <= 60) cfg.gsm_batch_size = val;
}
_config->save();
sendSuccess(req, "Transport configuration saved");
}
void WebPortal::handleGetOtaStatus(AsyncWebServerRequest* req) {
JsonDocument doc;
doc["status"] = (uint8_t)_ota->getStatus();
doc["progress"] = _ota->getProgress();
doc["message"] = _ota->getStatusMessage();
doc["update_available"] = _ota->isUpdateAvailable();
doc["needs_validation"] = _ota->needsValidation();
doc["running_partition"] = _ota->getRunningPartition();
doc["firmware_version"] = FW_VERSION;
if (_ota->isUpdateAvailable()) {
const FirmwareInfo& info = _ota->getAvailableUpdate();
JsonObject upd = doc["available"].to<JsonObject>();
upd["version"] = info.version;
upd["size"] = info.size;
upd["release_notes"] = info.releaseNotes;
upd["mandatory"] = info.mandatory;
}
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::handlePostOtaCheck(AsyncWebServerRequest* req) {
if (_ota->getStatus() != OtaStatus::IDLE) {
sendError(req, 409, "OTA operation in progress");
return;
}
bool available = _ota->checkForUpdate();
JsonDocument doc;
doc["success"] = true;
doc["update_available"] = available;
if (available) {
const FirmwareInfo& info = _ota->getAvailableUpdate();
doc["version"] = info.version;
doc["size"] = info.size;
doc["release_notes"] = info.releaseNotes;
doc["message"] = "Update available: v" + info.version;
} else {
doc["message"] = "No update available";
}
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::handlePostOtaInstall(AsyncWebServerRequest* req, uint8_t* data, size_t len) {
JsonDocument doc;
if (deserializeJson(doc, data, len)) {
sendError(req, 400, "Invalid JSON");
return;
}
String url = doc["url"] | "";
String checksum = doc["checksum"] | "";
if (url.length() == 0) {
sendError(req, 400, "URL required");
return;
}
_ota->requestUpdate(url, checksum);
sendSuccess(req, "Update started. Device will reboot when complete.");
}
void WebPortal::handleOtaUpload(AsyncWebServerRequest* req, const String& filename,
size_t index, uint8_t* data, size_t len, bool final) {
if (index == 0) {
Serial.printf("[OTA] Upload start: %s\n", filename.c_str());
if (!Update.begin(UPDATE_SIZE_UNKNOWN)) {
Serial.printf("[OTA] Update.begin failed: %s\n", Update.errorString());
return;
}
}
if (Update.write(data, len) != len) {
Serial.printf("[OTA] Write failed: %s\n", Update.errorString());
return;
}
if (final) {
if (Update.end(true)) {
Serial.printf("[OTA] Upload complete: %u bytes\n", index + len);
} else {
Serial.printf("[OTA] Update.end failed: %s\n", Update.errorString());
}
}
}
void WebPortal::handlePostOtaRollback(AsyncWebServerRequest* req) {
if (!_ota->needsValidation()) {
sendError(req, 400, "No pending firmware to rollback");
return;
}
sendSuccess(req, "Rolling back. Device will reboot...");
delay(500);
_ota->rollback();
}
void WebPortal::handleGetOtaConfig(AsyncWebServerRequest* req) {
JsonDocument doc;
DeviceConfig& cfg = _config->get();
doc["check_interval_hours"] = cfg.ota_check_interval_hours;
doc["auto_update"] = cfg.ota_auto_update;
String out;
serializeJson(doc, out);
sendJsonResponse(req, 200, out);
}
void WebPortal::handlePostOtaConfig(AsyncWebServerRequest* req, uint8_t* data, size_t len) {
JsonDocument doc;
if (deserializeJson(doc, data, len)) {
sendError(req, 400, "Invalid JSON");
return;
}
DeviceConfig& cfg = _config->get();
if (doc.containsKey("check_interval_hours")) {
uint8_t val = doc["check_interval_hours"] | 6;
if (val >= 1 && val <= 168) cfg.ota_check_interval_hours = val;
}
if (doc.containsKey("auto_update")) {
uint8_t val = doc["auto_update"] | 1;
if (val <= 2) cfg.ota_auto_update = val;
}
_config->save();
sendSuccess(req, "OTA configuration saved");
}
void WebPortal::handleRestart(AsyncWebServerRequest* req) {
sendSuccess(req, "Device restarting in 2 seconds...");
delay(2000);
ESP.restart();
}
void WebPortal::handleFactoryReset(AsyncWebServerRequest* req) {
sendSuccess(req, "Factory reset complete. Device restarting...");
delay(500);
_config->reset();
_config->save();
delay(500);
ESP.restart();
}