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>
881 lines
28 KiB
C++
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();
|
|
}
|