feat(provision): /provision defaults package — runner, CLI, boot auto-apply, fetch

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 <url> [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)
This commit is contained in:
agessaman
2026-07-10 12:56:03 -07:00
parent e32530952f
commit cf4a71a3d9
5 changed files with 668 additions and 1 deletions
+26
View File
@@ -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
+26
View File
@@ -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
+6 -1
View File
@@ -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;
+24
View File
@@ -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
+586
View File
@@ -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 <url> [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 <Arduino.h>
#include "CommonCLI.h"
#if defined(WITH_MQTT_BRIDGE) && defined(ESP_PLATFORM)
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#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<N>"). 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 <url> [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 <url>]");
}
return true;
}