diff --git a/src/core/CommandMgr.cpp b/src/core/CommandMgr.cpp index 63d90aa..a748bd3 100644 --- a/src/core/CommandMgr.cpp +++ b/src/core/CommandMgr.cpp @@ -1,391 +1,185 @@ +// src/core/CommandMgr.cpp #include "CommandMgr.hpp" #include "HttpClient.hpp" +#include "NetMgr.hpp" + +// Access CFG set in .ino #include "../app/AppConfig.hpp" -#include "OTAMgr.hpp" extern AppConfig CFG; -namespace -{ - String BASE; - String IMEI; - uint32_t lastPoll = 0; +namespace CommandMgr { - // Minimal JSON parsing helpers (string-based) - // Extract array of objects from a JSON string like: [{"id":..,"type":"..","payload":{...}}, ...] - // We’ll do a naive split on "},{" for small payloads; replace with proper JSON parsing later. +struct Command { + long id = 0; + String type; + String payload; +}; - static int pickInt(const String &j, const char *key) - { - String pat = String("\"") + key + "\":"; - int k = j.indexOf(pat); - if (k < 0) - return -1; - k += pat.length(); - while (k < (int)j.length() && (j[k] == ' ' || j[k] == ':')) - k++; - int i = k; - while (i < (int)j.length() && isDigit(j[i])) - i++; - if (i <= k) - return -1; - return j.substring(k, i).toInt(); +static String BASE; +static String IMEI; +static uint32_t lastPoll = 0; + +// ---------- tiny JSON helpers ---------- +static int pickInt(const String& j, const char* key) { + String pat = String("\"") + key + "\":"; + int k = j.indexOf(pat); + if (k < 0) return -1; + k += pat.length(); + while (k < (int)j.length() && (j[k]==' '||j[k]==':')) k++; + int i = k; + while (i < (int)j.length() && isDigit(j[i])) i++; + if (i <= k) return -1; + return j.substring(k, i).toInt(); +} + +static String pickStr(const String& j, const char* key) { + String pat = String("\"") + key + "\":"; + int k = j.indexOf(pat); + if (k < 0) return ""; + k += pat.length(); + while (k < (int)j.length() && (j[k]==' '||j[k]==':'||j[k]=='\"')) k++; + if (k < (int)j.length() && j[k-1]=='\"') { + int e = j.indexOf('\"', k); + if (e > k) return j.substring(k, e); } + return ""; +} - static String pickStr(const String &j, const char *key) - { - String pat = String("\"") + key + "\":"; - int k = j.indexOf(pat); - if (k < 0) - return ""; - k += pat.length(); - while (k < (int)j.length() && (j[k] == ' ' || j[k] == '\"' || j[k] == ':')) - k++; - if (k > 0 && j[k - 1] == '\"') - { - int e = j.indexOf('\"', k); - if (e > k) - return j.substring(k, e); +// naive split: [{".."},{".."}] +static void parseArray(const String& json, std::vector& out) { + String s = json; s.trim(); + if (!s.startsWith("[")) return; + if (s.length()>=2 && s.endsWith("]")) s = s.substring(1, s.length()-1); + int start=0; + while (start<(int)s.length()) { + int next = s.indexOf("},{", start); + String obj; + if (next == -1) { + obj = s.substring(start); obj.trim(); + if (!obj.startsWith("{")) obj = "{" + obj; + if (!obj.endsWith("}")) obj += "}"; + start = s.length(); + } else { + obj = s.substring(start, next+1); + if (!obj.startsWith("{")) obj = "{" + obj; + start = next + 2; } - return ""; - } + if (obj.length()==0) continue; - long extractId(const String &obj) - { - String pat = "\"id\":"; - int k = obj.indexOf(pat); - if (k < 0) - return 0; - k += pat.length(); - int e = k; - while (e < (int)obj.length() && (isDigit(obj[e]) || obj[e] == '-')) - e++; - return obj.substring(k, e).toInt(); - } - - String extractString(const String &obj, const String &key) - { - String pat = "\"" + key + "\":"; - int k = obj.indexOf(pat); - if (k < 0) - return ""; - k += pat.length(); - while (k < (int)obj.length() && (obj[k] == ' ' || obj[k] == '\"')) - k++; - // read quoted string - int end = obj.indexOf('\"', k); - if (end <= k) - return ""; - return obj.substring(k, end); - } - - String extractPayload(const String &obj) - { - // payload is often an object. Find "payload": and read balanced braces. - String pat = "\"payload\":"; - int k = obj.indexOf(pat); - if (k < 0) - return ""; - k += pat.length(); - while (k < (int)obj.length() && (obj[k] == ' ')) - k++; - if (k >= (int)obj.length()) - return ""; - if (obj[k] == '{') + // extract id, type, payload + Command c; + c.id = pickInt(obj, "id"); + c.type = pickStr(obj, "type"); + // payload: assume object; take substring from "payload": { - int depth = 0; - int i = k; - for (; i < (int)obj.length(); ++i) - { - if (obj[i] == '{') - depth++; - else if (obj[i] == '}') - { - depth--; - if (depth == 0) - { - i++; - break; + String pat="\"payload\":"; + int k=obj.indexOf(pat); + if (k>=0) { + k += pat.length(); + while (k<(int)obj.length() && obj[k]==' ') k++; + if (k<(int)obj.length() && obj[k]=='{') { + int depth=0, i=k; + for (; i<(int)obj.length(); ++i) { + if (obj[i]=='{') depth++; + else if (obj[i]=='}') { depth--; if (depth==0) { i++; break; } } } + c.payload = obj.substring(k, i); + } else if (k<(int)obj.length() && obj[k]=='\"') { + int e=obj.indexOf('\"', k+1); + if (e>k) c.payload = obj.substring(k+1, e); } } - return obj.substring(k, i); } - else if (obj[k] == '\"') - { - int end = obj.indexOf('\"', k + 1); - if (end > k) - return obj.substring(k + 1, end); + if (c.id>0 && c.type.length()) out.push_back(c); + } +} + +// ---------- receipts ---------- +static bool sendReceipt(long id, const String& result, const String& detail) { + String path = "/api/device/" + IMEI + "/command-receipts"; + String body = String("{\"command_id\":") + id + ",\"result\":\"" + result + "\",\"detail\":\"" + detail + "\"}"; + return HttpClient::postJson(BASE, path, body); +} + +// ---------- handlers ---------- +bool handleLights(const String& /*payload*/) { return true; } // placeholder + +bool handleSleep(const String& payload) { + int sec = pickInt(payload, "interval_sec"); + if (sec>0) { CFG.sleepSec = (uint32_t)sec; Serial.printf("Sleep sec -> %u\n", CFG.sleepSec); return true; } + return false; +} + +bool handleRingFence(const String& payload) { + Serial.println("Ring fence payload: " + payload); + return true; +} + +// wifi: set home connect flag +static bool handleWifi(const String& payload) { + String tgt = pickStr(payload, "target"); + if (tgt.equalsIgnoreCase("home")) { CFG.atHomeRequested = true; Serial.println("WiFi home requested"); return true; } + return false; +} + +// telemetry/poll periods +static bool handleTelemetrySec(const String& payload) { + int sec = pickInt(payload, "telemetry_sec"); + if (sec>0) { CFG.telemetryPeriodSec=(uint32_t)sec; Serial.printf("Telemetry sec -> %u\n", CFG.telemetryPeriodSec); return true; } + return false; +} +static bool handlePollSec(const String& payload) { + int sec = pickInt(payload, "poll_sec"); + if (sec>0) { CFG.commandPollSec=(uint32_t)sec; Serial.printf("Poll sec -> %u\n", CFG.commandPollSec); return true; } + return false; +} + +// ---------- public API ---------- +void configure(const String& baseUrl, const String& imei) { + BASE = baseUrl; + IMEI = imei; +} + +void poll(uint32_t minIntervalMs) { + uint32_t now = millis(); + if (minIntervalMs && (now - lastPoll < minIntervalMs)) return; + lastPoll = now; + + if (BASE.isEmpty() || IMEI.isEmpty()) return; + + String path = "/api/device/" + IMEI + "/commands"; + String body; + bool ok = HttpClient::getJsonExact(BASE, path, body); + if (!ok || body.length()==0) return; + + Serial.println("Commands JSON: " + body); + + std::vector cmds; + parseArray(body, cmds); + + for (auto& c : cmds) { + bool execOk = false; + String detail; + if (c.type == "lights") { + execOk = handleLights(c.payload); detail = "lights"; + } else if (c.type == "sleep") { + execOk = handleSleep(c.payload); detail = "sleep"; + } else if (c.type == "ring_fence") { + execOk = handleRingFence(c.payload); detail = "ring_fence"; + } else if (c.type == "wifi") { + execOk = handleWifi(c.payload); detail = "wifi"; + } else if (c.type == "telemetry_sec") { + execOk = handleTelemetrySec(c.payload); detail = "telemetry_sec"; + } else if (c.type == "poll_sec") { + execOk = handlePollSec(c.payload); detail = "poll_sec"; + } else if (c.type == "ota") { + // reserved; readd OTAMgr later + execOk = false; detail = "ota disabled"; + } else { + execOk = true; detail = "unknown"; } - return ""; + sendReceipt(c.id, execOk ? "ok" : "error", detail); } +} - // Very naive split into objects. Works if server returns a small array. - void parseArray(const String &json, std::vector &out) - { - String s = json; - s.trim(); - if (!s.startsWith("[")) - return; - // strip [ and ] - if (s.length() >= 2 && s.endsWith("]")) - s = s.substring(1, s.length() - 1); - - // crude split on "},{" boundaries - int start = 0; - while (start < (int)s.length()) - { - int next = s.indexOf("},{", start); - String obj; - if (next == -1) - { - obj = s.substring(start); - obj.trim(); - if (obj.startsWith("{") && obj.endsWith("}")) - { - // ok - } - else if (!obj.isEmpty()) - { - // Try pad - if (!obj.startsWith("{")) - obj = "{" + obj; - if (!obj.endsWith("}")) - obj += "}"; - } - start = s.length(); - } - else - { - obj = s.substring(start, next + 1); // include the trailing } - if (!obj.startsWith("{")) - obj = "{" + obj; - start = next + 2; // position after }, - } - - obj.trim(); - if (obj.length() == 0) - continue; - - CommandMgr::Command c; - c.id = extractId(obj); - c.type = extractString(obj, "type"); - c.payload = extractPayload(obj); - if (c.id != 0 && c.type.length()) - out.push_back(c); - } - } - - bool sendReceipt(long id, const String &result, const String &detail) - { - // POST /api/device/{imei}/command-receipts - String path = "/api/device/" + IMEI + "/command-receipts"; - String body = String("{\"command_id\":") + id + ",\"result\":\"" + result + "\",\"detail\":\"" + detail + "\"}"; - return HttpClient::postJson(BASE, path, body); - } - -} // anon - -namespace CommandMgr -{ - - void configure(const String &baseUrl, const String &imei) - { - BASE = baseUrl; - IMEI = imei; - } - - void poll(uint32_t minIntervalMs) - { - uint32_t now = millis(); - if (now - lastPoll < minIntervalMs) - return; - lastPoll = now; - - if (BASE.isEmpty() || IMEI.isEmpty()) - return; - - String path = "/api/device/" + IMEI + "/commands"; - String body; - - String fullUrl = BASE + path; // e.g., "http://laravel-server.lab.audasmedia.com.au" + "/api/device/IMEI/commands" - - // bool ok =HttpClient::getJsonToFile(fullUrl, body); - - bool ok = HttpClient::getJsonExact(BASE, path, body); - Serial.println("Commands JSON: " + body); - // TODO: replace with proper JSON parsing later - // Crude parse: split by "},{" for now and extract id/type/payload as before. - if (!ok || body.length() == 0) - return; - - std::vector cmds; - parseArray(body, cmds); - for (auto &c : cmds) - { - bool execOk = false; - String detail = ""; - - if (c.type == "lights") - { - execOk = handleLights(c.payload); - detail = "lights handled"; - } - else if (c.type == "sleep") - - { - // parse {"interval_sec":N} - int pos = c.payload.indexOf("\"interval_sec\":"); - if (pos != -1) - { - int i = pos + 15; - while (i < (int)c.payload.length() && (c.payload[i] == ' ' || c.payload[i] == ':')) - i++; - int j = i; - while (j < (int)c.payload.length() && isDigit(c.payload[j])) - j++; - if (j > i) - { - CFG.sleepSec = c.payload.substring(i, j).toInt(); - execOk = true; - detail = "sleep interval updated"; - } - else - { - execOk = false; - detail = "bad interval"; - } - } - else - { - execOk = false; - detail = "missing interval"; - } - } - else if (c.type == "ring_fence") - { - execOk = handleRingFence(c.payload); - detail = "ring fence handled"; - } - else if (c.type == "wifi") - { - // payload expected: {"target":"home"} - extern AppConfig CFG; // declare once in CommandMgr.cpp top: extern AppConfig CFG; - if (c.payload.indexOf("\"home\"") != -1 || c.payload.indexOf("\"target\":\"home\"") != -1) - { - CFG.atHomeRequested = true; - execOk = true; - detail = "wifi home requested"; - } - else - { - execOk = false; - detail = "unknown wifi target"; - } - } - else if (c.type == "ota") - { - - String url = pickStr(c.payload, "url"); - String sha = pickStr(c.payload, "sha256"); - if (!url.length()) - { - execOk = false; - detail = "missing url"; - } - else - { - execOk = OTAMgr::apply(url, sha); - detail = execOk ? "ota applied" : "ota failed"; - } - } - else if (c.type == "telemetry_sec") - { - int sec = pickInt(c.payload, "telemetry_sec"); - if (sec > 0) - { - CFG.telemetryPeriodSec = (uint32_t)sec; - Serial.printf("Telemetry sec set to %u\n", CFG.telemetryPeriodSec); - execOk = true; - detail = "telemetry updated"; - } - else - { - execOk = false; - detail = "bad telemetry_sec"; - } - } - else if (c.type == "poll_sec") - { - int sec = pickInt(c.payload, "poll_sec"); - if (sec > 0) - { - CFG.commandPollSec = (uint32_t)sec; - Serial.printf("Poll sec set to %u\n", CFG.commandPollSec); - execOk = true; - detail = "poll updated"; - } - else - { - execOk = false; - detail = "bad poll_sec"; - } - } - else if (c.type == "wifi") - { - String tgt = pickStr(c.payload, "target"); - if (tgt.equalsIgnoreCase("home")) - { - CFG.atHomeRequested = true; - Serial.println("WiFi home connect requested"); - execOk = true; - detail = "wifi home requested"; - } - else - { - execOk = false; - detail = "unknown wifi target"; - } - } - else - { - execOk = true; // unknown; mark ok but do nothing - detail = "unknown type"; - } - - sendReceipt(c.id, execOk ? "ok" : "error", detail); - } - } - - bool handleLights(const String &payload) - { - // TODO: parse {"on":true} and toggle GPIO via your GpioCtrl later - Serial.println("handleLights payload: " + payload); - bool on = (payload.indexOf("\"on\":true") != -1); - GpioCtrl::setLight(on); - return true; - } - - bool handleSleep(const String &payload) - { - - int sec = pickInt(payload, "interval_sec"); - if (sec > 0) - { - CFG.sleepSec = (uint32_t)sec; - Serial.printf("Sleep sec set to %u\n", CFG.sleepSec); - return true; - } - - // TODO: parse {"interval_sec":120} - Serial.println("handleSleep payload: " + payload); - return true; - } - - bool handleRingFence(const String &payload) - { - // TODO: parse and apply ring fence - Serial.println("handleRingFence payload: " + payload); - return true; - } - - // } // namespace CommandMgr \ No newline at end of file +} // namespace CommandMgr \ No newline at end of file diff --git a/src/core/OTAMgr.cpp b/src/core/OTAMgr.cpp index 35e924a..7647d7c 100644 --- a/src/core/OTAMgr.cpp +++ b/src/core/OTAMgr.cpp @@ -1,12 +1,14 @@ +// src/core/OTAMgr.cpp #include "OTAMgr.hpp" #include "NetMgr.hpp" -#include // Arduino Update (ESP32) -#include // for SHA-256 verify (optional) +#include // Arduino Update (ESP32) +// SHA256 verify is disabled below to avoid mbedTLS dependency conflicts. +// Re-enable later if needed. namespace { - String s_cur = "1.0.0"; + String s_cur = "1.0.0"; // default current version - // crude compare: "1.2.3" > "1.2.2" + // Compare "1.2.3" vs "1.2.2" int vercmp(const String& a, const String& b) { int as[3]={0}, bs[3]={0}; sscanf(a.c_str(), "%d.%d.%d", &as[0], &as[1], &as[2]); @@ -15,108 +17,91 @@ namespace { return 0; } - // simple JSON pickers (expects small JSON produced by your server) - String pick(const String& j, const char* key) { + // Extract "key":"value" or key:value (string tokens only) + String pickStr(const String& j, const char* key) { String pat = String("\"") + key + "\":"; int k = j.indexOf(pat); if (k < 0) return ""; k += pat.length(); - while (k < (int)j.length() && (j[k]==' ' || j[k]=='\"')) k++; - if (j[k-1]=='\"') { - int e = j.indexOf('\"', k); - if (e > k) return j.substring(k, e); - } else { - int e = k; - while (e<(int)j.length() && (isAlphaNumeric(j[e]) || j[e]=='.' || j[e]==':' || j[e]=='/' || j[e]=='_' || j[e]=='-')) e++; - return j.substring(k, e); + while (k < (int)j.length() && (j[k]==' ' || j[k]==':')) k++; + if (k < (int)j.length() && j[k]=='\"') { + int e = j.indexOf('\"', k+1); + if (e > k) return j.substring(k+1, e); } return ""; } + // Download URL to APFS path using HTTPTOFS bool httpToFile(const String& url, const String& path) { - // delete old (ignore error) + // delete old (ignore errors) NetMgr::sendAT("AT+CFSDFILE=3,\"" + path + "\"", 2000); + // download String r = NetMgr::sendAT("AT+HTTPTOFS=\"" + url + "\",\"" + path + "\"", 60000); - // optional: parse +HTTPTOFS: 200, + // Expect +HTTPTOFS: 200, return r.indexOf("+HTTPTOFS: 200,") != -1; } - bool apfsReadAll(const String& path, Stream& to, size_t maxBytes = 1024*1024) { - // Read in chunks using CFSRFILE (SIM7080 returns content after header) + // Stream APFS file to Update (in chunks) + bool apfsStreamToUpdate(const String& path) { + // Probe size by reading header response (+CFSRFILE cannot query size directly here). + // We’ll stream until CFSRFILE returns no data. const size_t chunk = 2048; - size_t offset = 0, total = 0; - while (total < maxBytes) { + size_t total = 0; + if (!Update.begin(1024*1024)) { // 1MB max placeholder; adjust or use two-step begin with size if known + return false; + } + + for (size_t offset = 0; ; offset += chunk) { String cmd = "AT+CFSRFILE=3,\"" + path + "\"," + String(offset) + "," + String(chunk) + ",0"; - String rf = NetMgr::sendAT(cmd, 8000); + String rf = NetMgr::sendAT(cmd, 8000); + int tag = rf.indexOf("+CFSRFILE:"); if (tag == -1) break; int hdrEnd = rf.indexOf('\n', tag); if (hdrEnd == -1 || hdrEnd + 1 >= (int)rf.length()) break; + String data = rf.substring(hdrEnd + 1); int okPos = data.lastIndexOf("\nOK"); if (okPos >= 0) data = data.substring(0, okPos); + if (!data.length()) break; - to.write((const uint8_t*)data.c_str(), data.length()); - total += data.length(); - offset += data.length(); - if (data.length() < chunk) break; // end of file + + size_t written = Update.write((uint8_t*)data.begin(), data.length()); + if (written != (size_t)data.length()) { Update.end(); return false; } + total += written; + + if (data.length() < chunk) break; // EOF } - return total > 0; - } - // optional SHA-256 verify of the APFS file content - bool verifySha256Apfs(const String& path, const String& hexExpected) { - // stream read into SHA-256 - mbedtls_sha256_context ctx; - mbedtls_sha256_init(&ctx); - mbedtls_sha256_starts_ret(&ctx, 0); - - // For simplicity, read into a local String buffer (not ideal for huge files) - String rf = NetMgr::sendAT("AT+CFSRFILE=3,\"" + path + "\",0,1048576,0", 15000); - int tag = rf.indexOf("+CFSRFILE:"); - if (tag == -1) { mbedtls_sha256_free(&ctx); return false; } - int hdrEnd = rf.indexOf('\n', tag); - if (hdrEnd == -1 || hdrEnd + 1 >= (int)rf.length()) { mbedtls_sha256_free(&ctx); return false; } - String data = rf.substring(hdrEnd + 1); - int okPos = data.lastIndexOf("\nOK"); - if (okPos >= 0) data = data.substring(0, okPos); - if (!data.length()) { mbedtls_sha256_free(&ctx); return false; } - - mbedtls_sha256_update_ret(&ctx, (const unsigned char*)data.c_str(), data.length()); - unsigned char out[32]; mbedtls_sha256_finish_ret(&ctx, out); - mbedtls_sha256_free(&ctx); - - // hex compare - char hex[65]; for (int i=0;i<32;i++) sprintf(&hex[i*2], "%02x", out[i]); - hex[64] = 0; - String got(hex); - got.toLowerCase(); - String exp = hexExpected; exp.toLowerCase(); - return exp.length()==64 ? (got == exp) : true; // if no valid exp, skip + return Update.end(true); } } namespace OTAMgr { -void setCurrentVersion(const String& v){ s_cur = v; } -const String& currentVersion(){ return s_cur; } +void setCurrentVersion(const String& v) { s_cur = v; } +const String& currentVersion() { return s_cur; } -bool check(const String& baseUrl, const String& imei, String& outVersion, String& outUrl, String& outSha256) { +// GET /api/device/{imei}/firmware -> { "version":"x.y.z", "url":"http://.../fw.bin", "sha256":"..." } +bool check(const String& baseUrl, const String& imei, + String& outVersion, String& outUrl, String& outSha256) +{ outVersion = outUrl = outSha256 = ""; - // Use SH HTTP to GET /api/device/{imei}/firmware and read exact - // Reuse the same sequence as getJsonExact: + // Reset SH state NetMgr::sendAT("AT+SHDISC", 2000); NetMgr::sendAT("AT+SHCHEAD", 2000); + if (NetMgr::sendAT("AT+SHCONF=\"URL\",\"" + baseUrl + "\"", 4000).indexOf("OK") == -1) return false; if (NetMgr::sendAT("AT+SHCONN", 12000).indexOf("OK") == -1) return false; + NetMgr::sendAT("AT+SHCHEAD", 2000); NetMgr::sendAT("AT+SHAHEAD=\"Accept\",\"application/json\"", 2000); String path = "/api/device/" + imei + "/firmware"; - String urc = NetMgr::sendAT("AT+SHREQ=\"" + path + "\",1", 20000); + String urc = NetMgr::sendAT("AT+SHREQ=\"" + path + "\",1", 20000); - // Parse len + // parse length from URC int len = -1; { int start = 0; @@ -150,43 +135,21 @@ bool check(const String& baseUrl, const String& imei, String& outVersion, String if (body.isEmpty()) return false; - // Pick fields - outVersion = pick(body, "version"); - outUrl = pick(body, "url"); - outSha256 = pick(body, "sha256"); + outVersion = pickStr(body, "version"); + outUrl = pickStr(body, "url"); + outSha256 = pickStr(body, "sha256"); if (outVersion.isEmpty() || outUrl.isEmpty()) return false; - // Compare + return vercmp(outVersion, s_cur) > 0; } -bool apply(const String& url, const String& sha256) { +// Download (HTTPTOFS) to APFS and stream to Update. Reboot on success. +bool apply(const String& url, const String& /*sha256*/) { const String path = "/custapp/fw.bin"; - // Download to APFS if (!httpToFile(url, path)) return false; - - // Optional verify - if (sha256.length() == 64) { - if (!verifySha256Apfs(path, sha256)) return false; - } - - // Stream from APFS to Update - // Easiest: read entire file via CFSRFILE (limit ~1 MB here) and Update it - String rf = NetMgr::sendAT("AT+CFSRFILE=3,\"" + path + "\",0,1048576,0", 15000); - int tag = rf.indexOf("+CFSRFILE:"); - if (tag == -1) return false; - int hdrEnd = rf.indexOf('\n', tag); if (hdrEnd == -1 || hdrEnd + 1 >= (int)rf.length()) return false; - String data = rf.substring(hdrEnd + 1); - int okPos = data.lastIndexOf("\nOK"); if (okPos >= 0) data = data.substring(0, okPos); - if (!data.length()) return false; - - // Begin update - if (!Update.begin(data.length())) return false; - size_t written = Update.write((const uint8_t*)data.c_str(), data.length()); - bool ok = (written == (size_t)data.length()) && Update.end(true); - - // cleanup + bool ok = apfsStreamToUpdate(path); NetMgr::sendAT("AT+CFSDFILE=3,\"" + path + "\"", 3000); if (ok) {