367 lines
18 KiB
C++
367 lines
18 KiB
C++
#pragma once
|
|
|
|
#include <stddef.h> // size_t / NULL for the reply classifiers below
|
|
#include <stdint.h>
|
|
#include <string.h>
|
|
|
|
// Fork-owned, dependency-free spec for the WebConfig "config batch / reboot /
|
|
// stop" decision + timing core, plus its host tests (test/test_webconfig_batch/).
|
|
//
|
|
// This is the Phase 6 counterpart of MQTTLifecycle.h: a PURE state machine that
|
|
// captures exactly what src/helpers/esp32/WebConfigServer.cpp decides today, so
|
|
// the POST-accept / drain / result-read / reboot / stop transitions can be
|
|
// exercised deterministically without AsyncWebServer, ArduinoJson, WiFi, or the
|
|
// FreeRTOS mutex/refcount.
|
|
//
|
|
// WIRED: WebConfigServer.cpp calls these functions directly, so they are
|
|
// load-bearing and the host tests cover production behavior rather than a
|
|
// parallel copy of it. MAX_BATCH and STOP_WARN_MS in WebConfigServer.h alias
|
|
// kMaxBatch/kStopWarnMs here, so the constants cannot drift either.
|
|
//
|
|
// Two deliberate asymmetries remain between this spec and its caller:
|
|
//
|
|
// 1. finishRebootAt() returns 0 for "no reboot scheduled", but the caller must
|
|
// only ASSIGN _reboot_at when the result is non-zero. _reboot_at is not
|
|
// solely batch-owned -- the manual /api/reboot route arms it from the
|
|
// async_tcp task, possibly while a batch is still draining -- so an
|
|
// unconditional assign would silently cancel a manual reboot.
|
|
// 2. classifyPost() is consulted in two phases by handleConfigPost, because the
|
|
// change count is only known after the `set` map is parsed, and parsing must
|
|
// not precede the Replay/Busy answer (a replayed POST carrying a bad key
|
|
// must still receive its 202).
|
|
//
|
|
// The file:line references below point at the behavior each function mirrors.
|
|
//
|
|
// Behavior source (all line refs against WebConfigServer.{h,cpp} at the time of
|
|
// writing): constants at .h:90-96,155-161; POST accept at .cpp:610-719; drain at
|
|
// .cpp:289-334; result read at .cpp:721-791; reboot fire at .cpp:262-265;
|
|
// isRebootPending at .cpp:70-74; stop gating at .cpp:185-255.
|
|
namespace WebConfigBatch {
|
|
|
|
// Constants, verbatim from WebConfigServer.
|
|
static const int kMaxBatch = 24; // .h:90 MAX_BATCH
|
|
static const uint32_t kDrainPacingMs = 25; // .cpp:296 inter-command gap
|
|
static const uint32_t kRebootFallbackMs = 30000; // .cpp:331 drain-finish fallback
|
|
static const uint32_t kRebootConfirmMs = 3000; // .cpp:784 first result-read arm
|
|
static const uint32_t kSetupWiFiConnectTimeoutMs = 20000;
|
|
static const uint32_t kSetupHandoffRebootConfirmMs = 1000;
|
|
static const uint32_t kStopWarnMs = 10000; // .h:95 STOP_WARN_MS
|
|
static const uint32_t kFullSetupApWindowMs = 30UL * 60UL * 1000UL;
|
|
|
|
// The batch lifecycle. A fresh POST moves Idle->Pending; the drainer moves
|
|
// Pending->Done; Done stays re-readable until the next POST claims the slot;
|
|
// finalizeTeardown() resets to Idle.
|
|
enum class State : uint8_t {
|
|
Idle = 0,
|
|
Pending,
|
|
Done,
|
|
};
|
|
|
|
// millis() idioms. elapsedMs uses unsigned wraparound (correct across one 32-bit
|
|
// rollover). scheduleAt mirrors the production wrap-guard: every _reboot_at /
|
|
// _stop_warn_at assignment does `if (t == 0) t = 1;` so 0 keeps meaning
|
|
// "unscheduled" even when the deadline lands exactly on the rollover boundary.
|
|
static inline uint32_t elapsedMs(uint32_t now, uint32_t then) { return now - then; }
|
|
static inline uint32_t scheduleAt(uint32_t now, uint32_t delay) {
|
|
const uint32_t t = now + delay;
|
|
return t == 0 ? 1u : t;
|
|
}
|
|
// Signed wrap-safe "deadline reached", matching the production
|
|
// `(int32_t)(now - deadline) >= 0` comparisons.
|
|
static inline bool deadlineReached(uint32_t now, uint32_t deadline) {
|
|
return (int32_t)(now - deadline) >= 0;
|
|
}
|
|
|
|
// FULL images expose an unconfigured setup AP for one bounded, boot-local
|
|
// window. Activity and connected stations do not extend it: until an SSID is
|
|
// saved the WiFi radio must go dark after 30 minutes. A reboot constructs a new
|
|
// WebConfigServer and therefore starts a new automatic window without
|
|
// persisting a timeout state. An explicit later `start webconfig` is still an
|
|
// operator override. timeout_ms == 0 keeps this policy disabled for profiles
|
|
// that retain the legacy idle-only behavior.
|
|
static inline bool unconfiguredSetupWindowExpired(bool setup_mode,
|
|
bool has_saved_ssid,
|
|
uint32_t now,
|
|
uint32_t setup_started_at,
|
|
uint32_t timeout_ms) {
|
|
return timeout_ms != 0 && setup_mode && !has_saved_ssid
|
|
&& elapsedMs(now, setup_started_at) >= timeout_ms;
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// POST accept classification (.cpp:637-718). Precedence, verbatim from the
|
|
// source: an in-flight/finished batch with the SAME reqid is an idempotent
|
|
// replay (commands are NOT re-applied); a DIFFERENT reqid while a batch is still
|
|
// PENDING is rejected as busy; otherwise a batch with no changes and no reboot
|
|
// is a no-op, and anything else is accepted. Note the asymmetry: a different
|
|
// reqid while DONE is NOT busy -- the new batch overwrites the DONE slot.
|
|
//
|
|
// Assumes the request already passed reqid grammar (WebConfigKeys::wcIsValidReqId)
|
|
// and per-key allowlist/secret validation, which are covered by test_webconfig_keys.
|
|
// --------------------------------------------------------------------------
|
|
enum class PostOutcome : uint8_t {
|
|
Replay, // 202; reqid matches the current batch, commands not re-applied
|
|
Busy, // 409; a different batch is still PENDING
|
|
Accept, // 202; a new batch is accepted (from Idle, or overwriting a Done slot)
|
|
NoChanges, // 400; nothing to do (no changes and no reboot requested)
|
|
};
|
|
|
|
static inline PostOutcome classifyPost(State state, bool reqid_matches_current,
|
|
int change_count, bool reboot_after) {
|
|
if (state != State::Idle && reqid_matches_current) return PostOutcome::Replay;
|
|
if (state == State::Pending) return PostOutcome::Busy; // reqid differs (match handled above)
|
|
if (change_count <= 0 && !reboot_after) return PostOutcome::NoChanges;
|
|
return PostOutcome::Accept;
|
|
}
|
|
|
|
// The state string a Replay body reports mirrors the batch state (.cpp:640):
|
|
// "done" when the matched batch already finished, else "pending".
|
|
static inline const char* replayStateName(State state) {
|
|
return state == State::Done ? "done" : "pending";
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Drain (.cpp:289-333). One command per tick.
|
|
// --------------------------------------------------------------------------
|
|
|
|
// The drainer waits only BETWEEN commands: never before the first (batch_next
|
|
// == 0 runs immediately and fires onConfigBatchStart), never after the last, and
|
|
// otherwise until the 25 ms pacing gap elapses. The pacing compare is SIGNED to
|
|
// mirror the source verbatim (.cpp:296 `(int32_t)(now - _batch_last_cmd) < 25`),
|
|
// matching deadlineReached()'s signedness; for all reachable inputs (elapsed
|
|
// 0..25 ms) it is identical to the unsigned form.
|
|
static inline bool drainMustWait(int batch_next, int batch_count,
|
|
uint32_t now, uint32_t last_cmd_ms) {
|
|
return batch_next > 0 && batch_next < batch_count &&
|
|
(int32_t)(now - last_cmd_ms) < (int32_t)kDrainPacingMs;
|
|
}
|
|
|
|
// all_ok is a sticky AND across command replies; a reply counts as ok iff it
|
|
// begins with "OK" (.cpp:314). Once false it stays false.
|
|
static inline bool nextAllOk(bool prev_all_ok, bool reply_is_ok) {
|
|
return prev_all_ok && reply_is_ok;
|
|
}
|
|
|
|
// The batch is finished once the post-increment drain index reaches the count
|
|
// (.cpp:319-321).
|
|
static inline bool drainFinished(int batch_next_after_increment, int batch_count) {
|
|
return batch_next_after_increment >= batch_count;
|
|
}
|
|
|
|
// On finish, a reboot-requested + all-ok batch arms the 30 s fallback deadline;
|
|
// a partially-failed batch (all_ok == false) never reboots (.cpp:324-333).
|
|
// Returns the reboot_at deadline, or 0 for "no reboot scheduled".
|
|
static inline uint32_t finishRebootAt(bool batch_reboot, bool batch_all_ok, uint32_t now) {
|
|
return (batch_reboot && batch_all_ok) ? scheduleAt(now, kRebootFallbackMs) : 0u;
|
|
}
|
|
|
|
// A successful setup-portal save that changes station credentials is held in
|
|
// Pending while AP+STA joins the selected network. That makes the DHCP address
|
|
// available to the captive page before its setup AP disappears. When the
|
|
// shared ESP-NOW channel changes, the station must remain on the old boot
|
|
// channel until reboot, so credentials are applied together on the next boot
|
|
// instead of attempting this live handoff. Other saves keep the ordinary
|
|
// batch/reboot path.
|
|
static inline bool shouldStartSetupWiFiHandoff(bool setup_mode,
|
|
bool batch_reboot,
|
|
bool batch_all_ok,
|
|
bool wifi_credentials_changed,
|
|
bool has_wifi_ssid,
|
|
bool espnow_channel_changed) {
|
|
return setup_mode && batch_reboot && batch_all_ok
|
|
&& wifi_credentials_changed && has_wifi_ssid
|
|
&& !espnow_channel_changed;
|
|
}
|
|
|
|
static inline uint32_t setupWiFiConnectDeadline(uint32_t now) {
|
|
return scheduleAt(now, kSetupWiFiConnectTimeoutMs);
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Result read (.cpp:738-786).
|
|
// --------------------------------------------------------------------------
|
|
enum class ResultOutcome : uint8_t {
|
|
Idle, // 200 "idle" -- no batch; any valid reqid is echoed
|
|
Unknown, // 404 -- a batch exists but the reqid does not match it
|
|
Pending, // 200 "pending"
|
|
Done, // 200 "done" (+ per-command results)
|
|
};
|
|
|
|
static inline ResultOutcome classifyResult(State state, bool reqid_matches_current) {
|
|
if (state == State::Idle) return ResultOutcome::Idle; // no reqid check while idle
|
|
if (!reqid_matches_current) return ResultOutcome::Unknown;
|
|
return state == State::Pending ? ResultOutcome::Pending : ResultOutcome::Done;
|
|
}
|
|
|
|
// The "reboot" flag reported in a Done body (.cpp:767): only a fully-OK,
|
|
// reboot-requested batch advertises a pending reboot.
|
|
static inline bool doneReportsReboot(bool batch_reboot, bool batch_all_ok) {
|
|
return batch_reboot && batch_all_ok;
|
|
}
|
|
|
|
// The first Done read arms the confirmed (3 s) reboot exactly once (.cpp:777-786):
|
|
// the !already_armed guard makes later reads idempotent, so polling cannot push
|
|
// the deadline out. When this returns true the caller sets armed = true and
|
|
// reboot_at = confirmRebootAt(now).
|
|
static inline bool shouldArmConfirmReboot(State state, bool batch_reboot,
|
|
bool batch_all_ok, bool already_armed) {
|
|
return state == State::Done && batch_reboot && batch_all_ok && !already_armed;
|
|
}
|
|
static inline uint32_t confirmRebootAt(uint32_t now) {
|
|
return scheduleAt(now, kRebootConfirmMs);
|
|
}
|
|
|
|
// The browser retains the rendered handoff page after the AP disappears, so
|
|
// only a brief response-flush delay is needed before rebooting.
|
|
static inline uint32_t confirmSetupHandoffRebootAt(uint32_t now) {
|
|
return scheduleAt(now, kSetupHandoffRebootConfirmMs);
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// CLI sequences (/api/cli). The terminal shares this one deferred-command slot
|
|
// with config saves rather than owning a second MAX_BATCH array: both drain on
|
|
// the loop task, both are single-slot, and a duplicate would cost ~8 KB of
|
|
// permanently resident RAM. Sharing also makes a save and a CLI run mutually
|
|
// exclusive, which they must be.
|
|
//
|
|
// Two things differ from a config save:
|
|
// 1. results stream. A save's results appear only when the whole batch is
|
|
// Done; a CLI read hands back whatever has executed so far, so a pasted
|
|
// sequence fills the terminal command by command.
|
|
// 2. the reboot is not requested by a `reboot` flag on the request but by the
|
|
// word `reboot` appearing in the sequence. It is deferred rather than
|
|
// executed, because Board::reboot() does not return and would take the node
|
|
// down before the client could read a single result.
|
|
// --------------------------------------------------------------------------
|
|
|
|
// Results returned by one read. Bounds the JSON document built on the
|
|
// async_tcp task; a longer sequence pages across successive reads.
|
|
static const int kCliResultPage = 8;
|
|
|
|
static inline int cliPageCount(int from, int produced, int page) {
|
|
const int pending = produced - from;
|
|
if (pending <= 0) return 0;
|
|
return pending > page ? page : pending;
|
|
}
|
|
|
|
// "done" means the client has been handed every result, not merely that
|
|
// execution finished: the last page may still be unread, and a client that
|
|
// stops polling at "done" would lose it.
|
|
static inline bool cliReadIsFinal(State state, int from, int page_count, int total) {
|
|
return state == State::Done && from + page_count >= total;
|
|
}
|
|
|
|
// A trailing `reboot` is withheld when any command in the sequence failed,
|
|
// exactly as a config save's is. The operator asked for the reboot, but
|
|
// rebooting into a half-applied config -- over a link they may not get back --
|
|
// is the worse failure, and the result body reports the refusal.
|
|
static inline bool cliRebootAllowed(bool has_reboot, bool all_ok) {
|
|
return has_reboot && all_ok;
|
|
}
|
|
|
|
// CommonCLI has no single failure convention. Testing only for an "Err" prefix
|
|
// let five other shapes through as success -- including "Unknown command" and
|
|
// "unknown config: x", the two an operator hits most -- which coloured them
|
|
// green AND let a queued reboot proceed after them.
|
|
//
|
|
// Every shape below is a literal from CommonCLI.cpp / CommonCLI_Observer.cpp.
|
|
// This list is the fragile part of the design: a new failure string added there
|
|
// is silently a success here. Which is exactly why it must not be what decides
|
|
// whether to reboot -- see cliReplyConfirmsWrite.
|
|
static inline bool cliReplyIsFailure(const char* r) {
|
|
if (r == NULL || r[0] == 0) return false; // empty is normalised to "OK"
|
|
static const char* const kPrefixes[] = {
|
|
"Err", "ERR", "err", // "Err - ", "ERR: ", "Error: "
|
|
"(ERR", // "(ERR: clock cannot go backwards)"
|
|
"Unknown command",
|
|
"unknown config",
|
|
"??", // "??: <key>" from the get fallthrough
|
|
"Can't find", // "Can't find GPS"
|
|
};
|
|
for (size_t i = 0; i < sizeof(kPrefixes) / sizeof(kPrefixes[0]); i++) {
|
|
const char* p = kPrefixes[i];
|
|
size_t n = 0;
|
|
while (p[n]) n++;
|
|
bool match = true;
|
|
for (size_t j = 0; j < n; j++) {
|
|
if (r[j] != p[j]) { match = false; break; }
|
|
}
|
|
if (match) return true;
|
|
}
|
|
// "File system erase: Err" reports the failure at the END of the reply.
|
|
for (size_t i = 0; r[i]; i++) {
|
|
if (r[i] == ':' && r[i + 1] == ' ' && r[i + 2] == 'E' && r[i + 3] == 'r' &&
|
|
r[i + 4] == 'r') return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Whether a command's reply is allowed to gate the deferred reboot.
|
|
//
|
|
// Only writes are, and only writes have a reply convention worth trusting:
|
|
// every setter answers with an "OK" prefix. Diagnostics do not -- `memory`
|
|
// answers "Free: ...", a getter answers "> value" -- so letting them gate would
|
|
// mean guessing, and guessing wrong here either strands the operator (a
|
|
// harmless `memory` blocks their reboot) or reboots into a config that did not
|
|
// apply. The question the gate exists to answer is narrower than "did anything
|
|
// fail": it is "did every setting I asked for actually take".
|
|
static inline bool cliReplyGatesReboot(const char* cmd) {
|
|
if (cmd == NULL) return false;
|
|
const char* set = "set ";
|
|
const char* pwd = "password ";
|
|
bool is_set = true, is_pwd = true;
|
|
for (int i = 0; i < 4; i++) if (cmd[i] != set[i]) { is_set = false; break; }
|
|
for (int i = 0; i < 9; i++) if (cmd[i] != pwd[i]) { is_pwd = false; break; }
|
|
return is_set || is_pwd;
|
|
}
|
|
|
|
// A write took effect iff its reply starts with "OK" -- the one convention every
|
|
// setter in CommonCLI actually keeps.
|
|
static inline bool cliWriteSucceeded(const char* reply) {
|
|
return reply != NULL && reply[0] == 'O' && reply[1] == 'K';
|
|
}
|
|
|
|
// CommonCLI echoes a newly saved password instead of returning "OK". Mask
|
|
// that success before sending it over HTTP, while preserving unsupported-role
|
|
// errors from roles such as Companion which have no admin password.
|
|
static inline bool cliPasswordChanged(const char* reply) {
|
|
return cliWriteSucceeded(reply)
|
|
|| (reply != NULL && strncmp(reply, "password now:", 13) == 0);
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Reboot fire (.cpp:262-265) and isRebootPending (.cpp:70-74).
|
|
// --------------------------------------------------------------------------
|
|
static inline bool rebootDue(uint32_t reboot_at, uint32_t now) {
|
|
return reboot_at != 0 && deadlineReached(now, reboot_at);
|
|
}
|
|
|
|
// isRebootPending() reports true only for a config-save reboot in the Done
|
|
// state, so the manual /api/reboot path (batch_reboot == false) is deliberately
|
|
// NOT reported as pending even though _reboot_at is set.
|
|
static inline bool isConfigRebootPending(uint32_t reboot_at, bool batch_reboot, State state) {
|
|
return reboot_at != 0 && batch_reboot && state == State::Done;
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Stop gating (.cpp:244-255). Teardown waits indefinitely for in-flight async
|
|
// handlers to drain (refs == 0); the STOP_WARN_MS timer only triggers a one-time
|
|
// diagnostic -- it never forces teardown.
|
|
// --------------------------------------------------------------------------
|
|
enum class StopAction : uint8_t {
|
|
Finalize, // refs == 0: finalizeTeardown() may run now
|
|
Warn, // refs > 0 and the warn deadline passed, not yet warned: log once
|
|
Wait, // refs > 0: keep the session alive and wait
|
|
};
|
|
|
|
static inline StopAction stopStep(uint32_t handler_refs, bool already_warned,
|
|
uint32_t stop_warn_at, uint32_t now) {
|
|
if (handler_refs == 0) return StopAction::Finalize;
|
|
if (!already_warned && stop_warn_at != 0 && deadlineReached(now, stop_warn_at)) {
|
|
return StopAction::Warn;
|
|
}
|
|
return StopAction::Wait;
|
|
}
|
|
|
|
} // namespace WebConfigBatch
|