From cf4a71a3d9d7d2d97a76789e488c64a835f8a501 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 10 Jul 2026 12:56:03 -0700 Subject: [PATCH] =?UTF-8?q?feat(provision):=20/provision=20defaults=20pack?= =?UTF-8?q?age=20=E2=80=94=20runner,=20CLI,=20boot=20auto-apply,=20fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provision file is a plain-text list of CLI commands under a '#meshcore-provision v1' header, stored as /provision. Each line runs through CommonCLI::handleCommand() with the invoker's privileges, so validation and persistence match typing the commands; no persisted struct layout is touched. - provision / show / apply / remove on all builds - provision begin/end: serial-only paste capture (the file-creation path for non-WiFi builds) - provision fetch [insecure]: HTTPS download on WITH_MQTT_BRIDGE builds, validated against the bundled CA roots; stores only, never auto-applies, and never clobbers /provision with an invalid file - boot auto-apply at the end of begin() in simple_repeater and simple_room_server, with serial privileges, then reboot; the /provision_done marker is written before the run so a bad file can never cause a reboot loop - blocklist (erase, start ota, ota, password, set prv.key, reboot/ clkreboot/poweroff/shutdown, region load, provision) enforced on every apply path; _mqtt_prefs_hold refuses the whole file - 4KB file cap, 159-char line cap, unknown/invalid lines counted and skipped (forward-compatible) --- examples/simple_repeater/MyMesh.cpp | 26 ++ examples/simple_room_server/MyMesh.cpp | 26 ++ src/helpers/CommonCLI.cpp | 7 +- src/helpers/CommonCLI.h | 24 + src/helpers/CommonCLI_Provision.cpp | 586 +++++++++++++++++++++++++ 5 files changed, 668 insertions(+), 1 deletion(-) create mode 100644 src/helpers/CommonCLI_Provision.cpp diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 88a44fb5..9b3863e0 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1082,6 +1082,24 @@ void MyMesh::begin(FILESYSTEM *fs) { #if ENV_INCLUDE_GPS == 1 applyGpsPrefs(); #endif + + // Boot-time provisioning: if a /provision file is present and not yet applied, + // run it with serial privileges and reboot so every setting (radio params, + // bridge config) takes effect from a clean start. Placed at the very end of + // begin() so set-command callbacks (advert timers, bridge restarts) are safe + // to fire. autoApplyProvisionFile() writes /provision_done before running the + // file, so this cannot reboot-loop. + { + char reply[160]; + if (_cli.autoApplyProvisionFile(reply)) { + Serial.print("Provision auto-apply: "); Serial.println(reply); + Serial.flush(); + delay(500); + _cli.getBoard()->reboot(); // does not return + } else if (reply[0]) { + Serial.println(reply); + } + } } void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { @@ -1305,6 +1323,14 @@ void MyMesh::clearStats() { } void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { + // While 'provision begin' capture is active, serial lines must reach CommonCLI + // untouched (they get written to /provision, not executed) — skip the app-level + // command intercepts below. + if (sender_timestamp == 0 && _cli.provisionCaptureActive()) { + _cli.handleCommand(sender_timestamp, command, reply); + return; + } + if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation region_map = temp_map; // copy over the temp instance as new current map diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 81389ab1..b48d3373 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -783,6 +783,24 @@ void MyMesh::begin(FILESYSTEM *fs) { _alerter.begin(&_prefs, _cli.getObserverPrefs(), this, this); _alerter.setBridge(bridge); #endif + + // Boot-time provisioning: if a /provision file is present and not yet applied, + // run it with serial privileges and reboot so every setting (radio params, + // bridge config) takes effect from a clean start. Placed at the very end of + // begin() so set-command callbacks (advert timers, bridge restarts) are safe + // to fire. autoApplyProvisionFile() writes /provision_done before running the + // file, so this cannot reboot-loop. + { + char reply[160]; + if (_cli.autoApplyProvisionFile(reply)) { + Serial.print("Provision auto-apply: "); Serial.println(reply); + Serial.flush(); + delay(500); + _cli.getBoard()->reboot(); // does not return + } else if (reply[0]) { + Serial.println(reply); + } + } } void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { @@ -950,6 +968,14 @@ void MyMesh::formatPacketStatsReply(char *reply) { } void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { + // While 'provision begin' capture is active, serial lines must reach CommonCLI + // untouched (they get written to /provision, not executed) — skip the app-level + // command intercepts below. + if (sender_timestamp == 0 && _cli.provisionCaptureActive()) { + _cli.handleCommand(sender_timestamp, command, reply); + return; + } + if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation region_map = temp_map; // copy over the temp instance as new current map diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index b9ad2eea..c03f8e3d 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -57,9 +57,11 @@ static const size_t COM_PREFS_TAIL_BYTES = 5; void CommonCLI::loadPrefs(FILESYSTEM* fs) { + _fs = fs; // cached for the provision command family (CommonCLI_Provision.cpp) + bool is_fresh_install = false; bool is_upgrade = false; - + if (fs->exists("/com_prefs")) { loadPrefsInt(fs, "/com_prefs"); // new filename } else if (fs->exists("/node_prefs")) { @@ -694,6 +696,9 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) { } void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) { + // Provision paste-capture and the 'provision' command family (fork-owned, + // CommonCLI_Provision.cpp) run first so capture mode sees every serial line. + if (handleProvisionCommand(sender_timestamp, command, reply)) return; // Observer-only top-level commands (ota check/update, tls.bundletest, alert test) // live in CommonCLI_Observer.cpp. if (handleObserverCommand(sender_timestamp, command, reply)) return; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index ef4a797c..4f3cd562 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -391,6 +391,20 @@ class CommonCLI { #endif bool _com_prefs_needs_upgrade = false; // old-format /com_prefs detected; rewrite once after load + // Provisioning (/provision defaults package) — implementation lives in the + // fork-owned CommonCLI_Provision.cpp. _fs is cached by loadPrefs() so the + // provision command family can reach the filesystem without re-plumbing + // every handleCommand call site. No persisted struct layout is involved. + FILESYSTEM* _fs = nullptr; + bool _prov_capture = false; // serial 'provision begin' paste-capture active + bool _prov_capture_got_header = false; // first non-blank captured line validated + uint16_t _prov_capture_lines = 0; + uint32_t _prov_capture_bytes = 0; + + bool handleProvisionCommand(uint32_t sender_timestamp, char* command, char* reply); + void provisionCaptureLine(const char* line, char* reply); + void runProvisionFile(uint32_t sender_timestamp, char* reply); + mesh::RTCClock* getRTCClock() { return _rtc; } void savePrefs(); void loadPrefsInt(FILESYSTEM* _fs, const char* filename); @@ -420,6 +434,16 @@ public: void loadPrefs(FILESYSTEM* _fs); void savePrefs(FILESYSTEM* _fs); void handleCommand(uint32_t sender_timestamp, char* command, char* reply); + + // Provisioning hooks (see CommonCLI_Provision.cpp / PROVISIONING.md): + // provisionCaptureActive() — apps must route serial lines straight to + // handleCommand while true, bypassing app-level command intercepts, so the + // lines are captured to /provision instead of executed. + // autoApplyProvisionFile() — boot-time hook; runs /provision with serial + // privileges unless the /provision_done marker exists. Returns true if the + // file ran (reply holds the summary) and the caller should reboot. + bool provisionCaptureActive() const { return _prov_capture; } + bool autoApplyProvisionFile(char* reply); mesh::MainBoard* getBoard() { return _board; } uint8_t buildAdvertData(uint8_t node_type, uint8_t* app_data); #ifdef WITH_MQTT_BRIDGE diff --git a/src/helpers/CommonCLI_Provision.cpp b/src/helpers/CommonCLI_Provision.cpp new file mode 100644 index 00000000..a548ca3e --- /dev/null +++ b/src/helpers/CommonCLI_Provision.cpp @@ -0,0 +1,586 @@ +// CommonCLI_Provision.cpp — fork-owned support for the /provision defaults package +// (user docs in PROVISIONING.md). A provision file is a plain-text list of CLI +// commands under a "#meshcore-provision v1" header; each line is fed verbatim to +// CommonCLI::handleCommand(), so validation, privilege checks (the existing +// sender_timestamp == 0 guards) and persistence are exactly the same as typing the +// commands. Nothing here touches any persisted struct layout. +// +// Command family (all builds): +// provision — status: file? size? header version? marker? +// provision show [start] — serial: full dump; remote: paged lines +// provision apply — run the file with the invoker's privileges +// provision remove — delete /provision and /provision_done +// provision begin / provision end — serial-only paste capture into /provision +// provision fetch [insecure] — HTTPS/HTTP download (WITH_MQTT_BRIDGE builds) +// +// Boot auto-apply is autoApplyProvisionFile(); apps call it at the very end of +// begin() and reboot when it returns true. The /provision_done marker is written +// BEFORE the file runs, so a crash or reset mid-apply can never cause a boot loop. + +#include +#include "CommonCLI.h" + +#if defined(WITH_MQTT_BRIDGE) && defined(ESP_PLATFORM) +#include +#include +#include +#endif + +#define PROVISION_FILE "/provision" +#define PROVISION_MARKER "/provision_done" +#define PROVISION_MAX_SIZE 4096 +#define PROVISION_MAX_LINE 159 // matches the 160-byte serial command buffer +#define PROVISION_VERSION 1 + +static const char PROVISION_HEADER_PREFIX[] = "#meshcore-provision v"; + +// --------------------------------------------------------------------------- +// filesystem open helpers (platform patterns mirror saveMQTTPrefs/openAppend) + +static File provOpenRead(FILESYSTEM* fs) { +#if defined(RP2040_PLATFORM) + return fs->open(PROVISION_FILE, "r"); +#else + return fs->open(PROVISION_FILE); +#endif +} + +static File provOpenWrite(FILESYSTEM* fs, const char* path) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + fs->remove(path); + File f = fs->open(path, FILE_O_WRITE); + return f; +#elif defined(RP2040_PLATFORM) + return fs->open(path, "w"); +#else + return fs->open(path, "w", true); +#endif +} + +static File provOpenAppend(FILESYSTEM* fs) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + return fs->open(PROVISION_FILE, FILE_O_WRITE); // Adafruit LittleFS: opens at end +#elif defined(RP2040_PLATFORM) + return fs->open(PROVISION_FILE, "a"); +#else + return fs->open(PROVISION_FILE, "a", true); +#endif +} + +// --------------------------------------------------------------------------- +// small parsing helpers + +// Parse a header line ("#meshcore-provision v"). Returns the version, or -1. +static int provParseHeader(const char* line) { + const size_t plen = sizeof(PROVISION_HEADER_PREFIX) - 1; + if (strncmp(line, PROVISION_HEADER_PREFIX, plen) != 0) return -1; + const char* vp = line + plen; + if (*vp < '0' || *vp > '9') return -1; + return atoi(vp); +} + +// Commands never run from a provision file. Everything here either destroys state +// (erase), reboots/halts/flashes mid-apply (reboot/clkreboot/poweroff/shutdown/ota), +// changes credentials a defaults package has no business touching (password, +// prv.key), recurses (provision), or enters an interactive multi-line mode that +// cannot work when lines are fed from a file (region load). +static const char* const PROVISION_BLOCKLIST[] = { + "provision", + "erase", + "start ota", + "ota ", + "password ", + "set prv.key", + "reboot", + "clkreboot", + "poweroff", + "shutdown", + "region load", +}; + +static bool provisionBlocked(const char* line) { + for (size_t i = 0; i < sizeof(PROVISION_BLOCKLIST) / sizeof(PROVISION_BLOCKLIST[0]); i++) { + if (strncmp(line, PROVISION_BLOCKLIST[i], strlen(PROVISION_BLOCKLIST[i])) == 0) return true; + } + return false; +} + +// Case-insensitive prefix test (strncasecmp isn't reliably available everywhere). +static bool provPrefixNoCase(const char* s, const char* prefix) { + while (*prefix) { + if (tolower((unsigned char)*s) != tolower((unsigned char)*prefix)) return false; + s++; prefix++; + } + return true; +} + +// Heuristic over the CLI reply conventions: "Err...", "ERR:", "Error:", "(ERR: ...)", +// "Unknown command", "unknown config: ...", "??: ..." all mean the line didn't take. +static bool provisionReplyIsError(const char* reply) { + while (*reply == ' ' || *reply == '(') reply++; + if (provPrefixNoCase(reply, "err")) return true; + if (provPrefixNoCase(reply, "unknown")) return true; + if (reply[0] == '?' && reply[1] == '?') return true; + return false; +} + +// Read one newline-terminated line into buf, stripping '\r'. Returns false only at +// EOF with nothing read. A line longer than the buffer is consumed to its newline +// and flagged via *truncated. +static bool provisionReadLine(File& f, char* buf, size_t buf_size, bool* truncated) { + *truncated = false; + size_t n = 0; + bool got_any = false; + while (true) { + int c = f.read(); + if (c < 0) break; // EOF + got_any = true; + if (c == '\n') break; + if (c == '\r') continue; + if (n + 1 < buf_size) { + buf[n++] = (char)c; + } else { + *truncated = true; // keep consuming to end of line + } + } + buf[n] = 0; + return got_any; +} + +// --------------------------------------------------------------------------- +// core runner + +void CommonCLI::runProvisionFile(uint32_t sender_timestamp, char* reply) { + if (_fs == NULL) { strcpy(reply, "Err - filesystem not ready"); return; } +#ifdef WITH_MQTT_BRIDGE + if (_mqtt_prefs_hold) { + // /mqtt_prefs was written by newer firmware (see saveMQTTPrefs). Applying a + // provision file would rewrite it in this firmware's older format. + strcpy(reply, "Err - /mqtt_prefs is from newer firmware; not applying"); + return; + } +#endif + File f = provOpenRead(_fs); + if (!f) { strcpy(reply, "Err - no /provision file"); return; } + uint32_t fsize = f.size(); + if (fsize > PROVISION_MAX_SIZE) { + f.close(); + sprintf(reply, "Err - /provision too big (%u > %d bytes)", (unsigned)fsize, PROVISION_MAX_SIZE); + return; + } + + char line[PROVISION_MAX_LINE + 1]; + bool truncated; + bool got_header = false; + int applied = 0, failed = 0, skipped = 0; + + while (provisionReadLine(f, line, sizeof(line), &truncated)) { + if (truncated) { + failed++; + MESH_DEBUG_PRINTLN("provision: line too long, skipped"); + continue; + } + const char* p = line; + while (*p == ' ' || *p == '\t') p++; + if (*p == 0) continue; // blank line + + if (!got_header) { + // header must be the FIRST non-blank line — nothing is applied before this check + int ver = provParseHeader(p); + if (ver < 0) { + f.close(); + strcpy(reply, "Err - missing '#meshcore-provision v1' header"); + return; + } + if (ver != PROVISION_VERSION) { + f.close(); + sprintf(reply, "Err - unsupported provision version %d", ver); + return; + } + got_header = true; + continue; + } + if (*p == '#') continue; // comment + + if (provisionBlocked(p)) { + skipped++; + MESH_DEBUG_PRINTLN("provision: blocked '%s'", p); + continue; + } + + // Copy the line before handing it to handleCommand: the CLI mutates its + // command buffer in place, and our read buffer is reused for the next line. + char cmd[PROVISION_MAX_LINE + 1]; + strcpy(cmd, p); + char line_reply[160]; + line_reply[0] = 0; + handleCommand(sender_timestamp, cmd, line_reply); + MESH_DEBUG_PRINTLN("provision: '%s' -> %s", p, line_reply); + if (provisionReplyIsError(line_reply)) failed++; else applied++; + } + f.close(); + + if (!got_header) { strcpy(reply, "Err - /provision is empty"); return; } + sprintf(reply, "Provision: %d applied, %d failed, %d skipped", applied, failed, skipped); +} + +bool CommonCLI::autoApplyProvisionFile(char* reply) { + reply[0] = 0; + if (_fs == NULL) return false; + if (!_fs->exists(PROVISION_FILE) || _fs->exists(PROVISION_MARKER)) return false; + + // Marker FIRST: if a command below crashes or resets the device mid-apply, the + // next boot must not re-enter the runner (reboot-loop guard). Manual + // 'provision apply' deliberately ignores the marker. + File m = provOpenWrite(_fs, PROVISION_MARKER); + if (!m) { + strcpy(reply, "Err - cannot write /provision_done; provision not auto-applied"); + return false; + } + m.close(); + + runProvisionFile(0, reply); // serial privileges: this is the first-boot jump-start path + return true; +} + +// --------------------------------------------------------------------------- +// paste capture ('provision begin' ... 'provision end', serial-only) + +void CommonCLI::provisionCaptureLine(const char* line, char* reply) { + reply[0] = 0; // silent per-line so pasting a file doesn't flood the console + + const char* p = line; + while (*p == ' ' || *p == '\t') p++; + + if (!_prov_capture_got_header) { + if (*p == 0) return; // leading blank lines tolerated (and not written) + int ver = provParseHeader(p); + if (ver != PROVISION_VERSION) { + _prov_capture = false; + _fs->remove(PROVISION_FILE); + strcpy(reply, "Err - first line must be '#meshcore-provision v1'; capture aborted"); + return; + } + _prov_capture_got_header = true; + } + + size_t len = strlen(line); + if (len > PROVISION_MAX_LINE) { // can't happen via the 160-char serial buffer, but be safe + _prov_capture = false; + _fs->remove(PROVISION_FILE); + strcpy(reply, "Err - line too long; capture aborted"); + return; + } + if (_prov_capture_bytes + len + 1 > PROVISION_MAX_SIZE) { + _prov_capture = false; + _fs->remove(PROVISION_FILE); + sprintf(reply, "Err - /provision would exceed %d bytes; capture aborted", PROVISION_MAX_SIZE); + return; + } + + File f = provOpenAppend(_fs); + if (!f) { + _prov_capture = false; + strcpy(reply, "Err - write failed; capture aborted"); + return; + } + f.write((const uint8_t*)line, len); + f.write((uint8_t)'\n'); + f.close(); + _prov_capture_bytes += len + 1; + _prov_capture_lines++; +} + +// --------------------------------------------------------------------------- +// fetch (Phase 2 — WITH_MQTT_BRIDGE builds are all ESP32-family) + +#if defined(WITH_MQTT_BRIDGE) && defined(ESP_PLATFORM) + +#define PROV_FETCH_TOO_BIG -1000 // local sentinel, distinct from HTTPClient errors + +// One GET attempt with the given client. Returns the HTTP code (>0), an HTTPClient +// error (<0), or PROV_FETCH_TOO_BIG. On 200, streams the body into buf; *total may +// end up cap+1 to signal an over-cap body when the length wasn't known up front. +static int provisionHttpGet(WiFiClient& client, const char* url, char* buf, int cap, int* total) { + *total = 0; + HTTPClient http; + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.setConnectTimeout(10000); + http.setTimeout(15000); + if (!http.begin(client, url)) return HTTPC_ERROR_CONNECTION_REFUSED; + int code = http.GET(); + if (code == HTTP_CODE_OK) { + int expected = http.getSize(); // -1 when chunked/unknown + if (expected > cap) { + http.end(); + return PROV_FETCH_TOO_BIG; + } + WiFiClient* stream = http.getStreamPtr(); + int n = 0; + uint32_t deadline = millis() + 20000; + while ((int32_t)(millis() - deadline) < 0) { + int avail = stream->available(); + if (avail > 0) { + int want = cap + 1 - n; // read one byte past cap to detect oversize + if (want <= 0) break; + int r = stream->read((uint8_t*)buf + n, (avail < want) ? avail : want); + if (r > 0) n += r; + } else if (!http.connected()) { + break; + } else { + delay(10); + } + if (expected >= 0 && n >= expected) break; + } + *total = n; + } + http.end(); + return code; +} + +static void handleProvisionFetch(FILESYSTEM* fs, const char* args, char* reply) { + while (*args == ' ') args++; + if (*args == 0) { strcpy(reply, "Err - usage: provision fetch [insecure]"); return; } + if (WiFi.status() != WL_CONNECTED) { + strcpy(reply, "Err - WiFi not connected (set wifi.ssid / set wifi.pwd first)"); + return; + } + + char url[PROVISION_MAX_LINE + 1]; + const char* sp = strchr(args, ' '); + size_t ulen = sp ? (size_t)(sp - args) : strlen(args); + if (ulen >= sizeof(url)) { strcpy(reply, "Err - url too long"); return; } + memcpy(url, args, ulen); + url[ulen] = 0; + bool insecure = (sp != NULL) && (strstr(sp, "insecure") != NULL); + + bool is_https = strncmp(url, "https://", 8) == 0; + bool is_http = strncmp(url, "http://", 7) == 0; + if (!is_https && !is_http) { strcpy(reply, "Err - url must be http:// or https://"); return; } + + char* buf = (char*)malloc(PROVISION_MAX_SIZE + 1); + if (!buf) { strcpy(reply, "Err - out of memory"); return; } + + int total = 0; + int code; + if (is_http) { + WiFiClient plain; + code = provisionHttpGet(plain, url, buf, PROVISION_MAX_SIZE, &total); + } else if (insecure) { + WiFiClientSecure client; + client.setInsecure(); + code = provisionHttpGet(client, url, buf, PROVISION_MAX_SIZE, &total); + } else { + // Validate against the CA roots already bundled for the MQTT presets; try each. + const char* const roots[] = { GTS_ROOT_R4, ISRG_ROOT_X1 }; + code = HTTPC_ERROR_CONNECTION_REFUSED; + for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) { + WiFiClientSecure client; + client.setCACert(roots[i]); + code = provisionHttpGet(client, url, buf, PROVISION_MAX_SIZE, &total); + if (code > 0 || code == PROV_FETCH_TOO_BIG) break; // got an HTTP response, TLS was fine + } + } + + if (code == PROV_FETCH_TOO_BIG || total > PROVISION_MAX_SIZE) { + sprintf(reply, "Err - file too big (max %d bytes)", PROVISION_MAX_SIZE); + } else if (code == HTTP_CODE_OK) { + buf[total] = 0; + // Validate the header BEFORE touching /provision — never clobber an existing + // good file with garbage. The trust decision happens later, at apply time. + const char* p = buf; + while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++; + int ver = provParseHeader(p); + if (ver < 0) { + strcpy(reply, "Err - fetched file missing '#meshcore-provision v1' header; not saved"); + } else if (ver != PROVISION_VERSION) { + sprintf(reply, "Err - unsupported provision version %d; not saved", ver); + } else { + File f = provOpenWrite(fs, PROVISION_FILE); + if (!f) { + strcpy(reply, "Err - cannot write /provision"); + } else { + f.write((const uint8_t*)buf, total); + f.close(); + int lines = 0; + for (int i = 0; i < total; i++) { + if (buf[i] == '\n') lines++; + } + if (total > 0 && buf[total - 1] != '\n') lines++; + sprintf(reply, "OK - fetched %d bytes (%d lines); review with 'provision show', run 'provision apply'", + total, lines); + } + } + } else if (code > 0) { + sprintf(reply, "Err - HTTP %d", code); + } else { + snprintf(reply, 160, "Err - connection failed (%s)", HTTPClient::errorToString(code).c_str()); + } + free(buf); +} + +#endif // WITH_MQTT_BRIDGE && ESP_PLATFORM + +// --------------------------------------------------------------------------- +// command dispatch — called first from CommonCLI::handleCommand + +bool CommonCLI::handleProvisionCommand(uint32_t sender_timestamp, char* command, char* reply) { + // Paste capture: while active, every serial line is written to /provision + // instead of executed; 'provision end' closes the capture (without applying). + // Remote (sender_timestamp != 0) commands are unaffected by capture mode. + if (_prov_capture && sender_timestamp == 0) { + // tolerate trailing spaces on the end marker + bool is_end = false; + if (memcmp(command, "provision end", 13) == 0) { + const char* t = &command[13]; + while (*t == ' ') t++; + is_end = (*t == 0); + } + if (is_end) { + _prov_capture = false; + sprintf(reply, "OK - /provision: %u lines, %u bytes (not applied; run 'provision apply')", + (unsigned)_prov_capture_lines, (unsigned)_prov_capture_bytes); + } else { + provisionCaptureLine(command, reply); + } + return true; + } + + if (memcmp(command, "provision", 9) != 0) return false; + if (command[9] != 0 && command[9] != ' ') return false; + const char* args = (command[9] == ' ') ? &command[10] : ""; + while (*args == ' ') args++; + + if (_fs == NULL) { strcpy(reply, "Err - filesystem not ready"); return true; } + + if (*args == 0) { + // status + bool marker = _fs->exists(PROVISION_MARKER); + File f = provOpenRead(_fs); + if (!f) { + sprintf(reply, "no /provision; marker: %s", marker ? "present" : "absent"); + } else { + uint32_t size = f.size(); + // header version lives on the first non-blank line + char line[PROVISION_MAX_LINE + 1]; + bool truncated; + int ver = -1; + while (provisionReadLine(f, line, sizeof(line), &truncated)) { + const char* p = line; + while (*p == ' ' || *p == '\t') p++; + if (*p == 0) continue; + if (!truncated) ver = provParseHeader(p); + break; + } + f.close(); + if (ver >= 0) { + sprintf(reply, "/provision: %u bytes, v%d; marker: %s", (unsigned)size, ver, + marker ? "present" : "absent"); + } else { + sprintf(reply, "/provision: %u bytes, BAD HEADER; marker: %s", (unsigned)size, + marker ? "present" : "absent"); + } + } + } else if (strcmp(args, "show") == 0 || memcmp(args, "show ", 5) == 0) { + File f = provOpenRead(_fs); + if (!f) { strcpy(reply, "Err - no /provision file"); return true; } + char line[PROVISION_MAX_LINE + 1]; + bool truncated; + if (sender_timestamp == 0) { + // serial: print the whole file to the console, summary in reply + uint32_t size = f.size(); + int lines = 0; + Serial.println("--- /provision ---"); + while (provisionReadLine(f, line, sizeof(line), &truncated)) { + Serial.println(line); + lines++; + } + Serial.println("--- end ---"); + sprintf(reply, "/provision: %d lines, %u bytes", lines, (unsigned)size); + } else { + // remote: page lines into the 160-char reply — 'provision show [start]' + int start = (args[4] == ' ') ? atoi(&args[5]) : 1; + if (start < 1) start = 1; + const size_t reply_cap = 160; + const size_t reserve_for_next = 16; // room for "\n... next:NNN" + size_t used = 0; + int num = 0, next = 0; + reply[0] = 0; + while (provisionReadLine(f, line, sizeof(line), &truncated)) { + num++; + if (num < start) continue; + size_t len = strlen(line); + if (used + len + 1 >= reply_cap - reserve_for_next) { + if (used == 0) { + // single line longer than a page: show a truncated prefix so paging + // can still advance past it + len = reply_cap - reserve_for_next - 2; + memcpy(reply, line, len); + reply[len] = 0; + used = len; + next = num + 1; + } else { + next = num; + } + break; + } + if (used) reply[used++] = '\n'; + memcpy(reply + used, line, len); + used += len; + reply[used] = 0; + } + if (next) { + sprintf(reply + used, "%s... next:%d", used ? "\n" : "", next); + } else if (used == 0) { + strcpy(reply, "(no lines at or past start)"); + } + } + f.close(); + } else if (strcmp(args, "apply") == 0) { + // Runs with the INVOKER's privileges: remote applies can't change radio params + // or prv.key (same policy as typing the commands). Marker deliberately ignored. + if (_prov_capture) { + strcpy(reply, "Err - capture in progress; finish with 'provision end' first"); + } else { + runProvisionFile(sender_timestamp, reply); + } + } else if (strcmp(args, "remove") == 0) { + bool had_file = _fs->exists(PROVISION_FILE); + bool had_marker = _fs->exists(PROVISION_MARKER); + if (had_file) _fs->remove(PROVISION_FILE); + if (had_marker) _fs->remove(PROVISION_MARKER); + if (had_file || had_marker) { + sprintf(reply, "OK - removed%s%s", had_file ? " /provision" : "", + had_marker ? " /provision_done" : ""); + } else { + strcpy(reply, "OK - nothing to remove"); + } + } else if (strcmp(args, "begin") == 0) { + if (sender_timestamp != 0) { + strcpy(reply, "Err - 'provision begin' is serial-only"); + } else { + File f = provOpenWrite(_fs, PROVISION_FILE); // create/truncate + if (!f) { + strcpy(reply, "Err - cannot create /provision"); + } else { + f.close(); + _prov_capture = true; + _prov_capture_got_header = false; + _prov_capture_lines = 0; + _prov_capture_bytes = 0; + strcpy(reply, "OK - capturing to /provision; paste file, finish with 'provision end'"); + } + } + } else if (strcmp(args, "end") == 0) { + strcpy(reply, "Err - no capture in progress"); + } else if (memcmp(args, "fetch", 5) == 0 && (args[5] == 0 || args[5] == ' ')) { +#if defined(WITH_MQTT_BRIDGE) && defined(ESP_PLATFORM) + handleProvisionFetch(_fs, &args[5], reply); +#else + strcpy(reply, "Err - fetch not supported on this build"); +#endif + } else { + strcpy(reply, "Err - usage: provision [show|apply|remove|begin|end|fetch ]"); + } + return true; +}