#include "web_server.h" #include #include #include 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); }