The terminal has been driving the mock since it was built. This is the firmware
side, so it works on hardware.
Same 202 + reqid + poll contract as a config save, for the same reason:
CommonCLI touches prefs, the radio and the filesystem, none of which may be
reached from the async_tcp task. Commands go into the deferred slot and tick()
drains them on the loop task. Unlike a save this is not allowlisted — reaching
what the serial console reaches is the point, and execCommand() already passes
sender_timestamp 0, so the terminal gets exactly the serial console's
privilege. Authentication is the boundary, as it is there.
The CLI shares the config batch's slot 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. Each reader checks the kind, so neither
can serve the other's results.
Three things the mock could not have taught us:
- Board::reboot() does not return, so a drained `reboot` would take the node
down before the client read a single result. It is answered rather than
executed, and the batch arms the existing deferred-reboot path once the
results have been read — withheld if any command failed, exactly as a save
withholds one. clkreboot/poweroff/ota update do real work on the way down
and cannot be faked, so they still drop the connection; the UI warns first.
- `password <new>` echoes the new password in its reply. The config path
already scrubbed that by key; a CLI entry has no key, so it is matched on
the command. CLI commands are also kept out of the serial log entirely —
the browser session and the serial console are different audiences.
- MAX_BATCH is 24, not the 64 the page assumed. It is reported as
status.max_cmds instead of hardcoded, so the cap cannot drift.
Results stream and page (kCliResultPage = 8), and "done" means the client has
been handed every result, not merely that execution finished — otherwise a
client that stops polling at "done" loses the last page. Commands are never
echoed back: they may carry a secret, and the client matches by index.
New decisions live in WebConfigBatch.h with the rest, covered by three host
tests. Builds clean for heltec_v4_repeater_observer_mqtt; 22 batch + 14 keys
tests pass; the CLI audit reports 119/119 against the updated mock.
219 lines
9.3 KiB
C++
219 lines
9.3 KiB
C++
#pragma once
|
|
|
|
// Browser-based configuration portal for ESP32 MQTT observer builds.
|
|
//
|
|
// Two modes:
|
|
// - SETUP: open SoftAP + captive portal, raised automatically on first boot
|
|
// when no WiFi is configured (wifi_ssid empty), or manually via
|
|
// `start webconfig ap`. Save -> reboot; auto-stops after an idle timeout.
|
|
// - LAN: bound to the existing STA connection (owned by the MQTT bridge
|
|
// task), started via `start webconfig`, admin-password login required.
|
|
//
|
|
// Concurrency model: AsyncWebServer handlers run on the async_tcp task and
|
|
// must never touch the CLI, prefs persistence, or the radio. Config writes
|
|
// are marshaled into a single-slot command batch that tick() - called from
|
|
// MyMesh::loop() on the Arduino loop task - drains through the existing CLI
|
|
// `set` handlers. Prefs-struct reads and batch state are guarded by _mux.
|
|
// The HTTP server/routes live for the firmware lifetime; routes acquire the
|
|
// currently attached WebConfigServer session only for the synchronous handler
|
|
// call. This lets a stopped session be reclaimed without deleting an
|
|
// AsyncWebServer that may still be referenced by a partially received request.
|
|
//
|
|
// No persisted state: everything here is RAM-only, so prefs-file layouts
|
|
// (fleet-critical) are untouched.
|
|
|
|
#if defined(ESP_PLATFORM) && defined(WITH_MQTT_BRIDGE)
|
|
|
|
#define WITH_WEBCONFIG 1
|
|
|
|
#include <Arduino.h>
|
|
#include <freertos/FreeRTOS.h>
|
|
#include <freertos/semphr.h>
|
|
#include <helpers/WebConfigBatch.h>
|
|
|
|
class AsyncWebServer;
|
|
class AsyncWebServerRequest;
|
|
class DNSServer;
|
|
struct NodePrefs;
|
|
struct MQTTPrefs;
|
|
|
|
#ifndef WEBCONFIG_AP_IDLE_TIMEOUT_MS
|
|
#define WEBCONFIG_AP_IDLE_TIMEOUT_MS (10UL * 60UL * 1000UL)
|
|
#endif
|
|
#ifndef WEBCONFIG_SESSION_TTL_MS
|
|
#define WEBCONFIG_SESSION_TTL_MS (20UL * 60UL * 1000UL)
|
|
#endif
|
|
|
|
class WebConfigServer {
|
|
public:
|
|
enum Mode : uint8_t { MODE_OFF = 0, MODE_SETUP, MODE_LAN };
|
|
|
|
class Callbacks {
|
|
public:
|
|
// Run one CLI command. Called from tick() only (loop task); reply is 160 bytes.
|
|
virtual void execCommand(char* cmd, char* reply) = 0;
|
|
virtual void rebootNow() = 0;
|
|
// Bracket a config batch so bridge restarts triggered by individual
|
|
// `set` handlers can be coalesced into one.
|
|
virtual void onConfigBatchStart() {}
|
|
virtual void onConfigBatchEnd() {}
|
|
// Fill buf with the stats JSON snapshot. Called from tick() (loop task).
|
|
virtual void buildStatsJson(char* buf, size_t buf_size) = 0;
|
|
// Teardown finished (session + DNS freed, WiFi mode restored).
|
|
virtual void onWebConfigStopped() {}
|
|
};
|
|
|
|
WebConfigServer(NodePrefs* prefs, MQTTPrefs* obs, Callbacks* callbacks,
|
|
const uint8_t* pub_key, const char* fw_ver,
|
|
const char* role, const char* board_name);
|
|
~WebConfigServer();
|
|
|
|
// For the device display: true while a setup-mode portal is active.
|
|
// Fills the AP SSID and portal IP; either buffer may be NULL to just poll.
|
|
// Call from the loop task only (same task that changes the mode).
|
|
static bool getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t ip_len);
|
|
|
|
// For the device display: true once a config save completed and the node is
|
|
// about to reboot — ground truth for the user even if the browser lost its
|
|
// connection before the confirmation arrived. Loop task only.
|
|
static bool isRebootPending();
|
|
|
|
bool startSetupMode(char reply[]); // open SoftAP + DNS captive portal
|
|
bool startLanMode(char reply[]); // bind to existing STA connection
|
|
void requestStop(); // stop listening and detach this session
|
|
void tick(uint32_t now); // call every loop iteration
|
|
|
|
Mode mode() const { return _mode; }
|
|
bool isRunning() const { return _mode != MODE_OFF; }
|
|
bool isStopping() const { return _stopping; }
|
|
|
|
private:
|
|
// Batch/reboot/stop decisions and timings live in the fork-owned pure spec
|
|
// WebConfigBatch.h (host-tested by test/test_webconfig_batch). These aliases
|
|
// keep a single source of truth so the spec and this server cannot drift.
|
|
static const int MAX_BATCH = WebConfigBatch::kMaxBatch;
|
|
static const size_t MAX_BODY = 4096;
|
|
// A detached session normally drains immediately because handlers are short.
|
|
// If one does not, keep the session alive (safe) and emit a diagnostic rather
|
|
// than freeing memory still referenced by the async task.
|
|
static const uint32_t STOP_WARN_MS = WebConfigBatch::kStopWarnMs;
|
|
enum BatchState : uint8_t { BATCH_IDLE = 0, BATCH_PENDING, BATCH_DONE };
|
|
// What filled the shared slot. A config save comes from allowlisted form
|
|
// fields; a CLI sequence is arbitrary commands typed into the terminal. They
|
|
// share the slot (see WebConfigBatch.h) but differ in how results are read
|
|
// and in whether `key` means anything, so every reader checks the kind.
|
|
enum BatchKind : uint8_t { BATCH_CONFIG = 0, BATCH_CLI };
|
|
|
|
// BatchState and WebConfigBatch::State are deliberately kept as separate
|
|
// types (the enum is stored in a volatile member and used in prints); this
|
|
// is the one conversion point.
|
|
static WebConfigBatch::State toSpecState(BatchState s) {
|
|
switch (s) {
|
|
case BATCH_PENDING: return WebConfigBatch::State::Pending;
|
|
case BATCH_DONE: return WebConfigBatch::State::Done;
|
|
default: return WebConfigBatch::State::Idle;
|
|
}
|
|
}
|
|
struct BatchEntry {
|
|
char key[24]; // allowlisted config key (echoed back to the UI); empty for CLI entries
|
|
char cmd[160]; // full CLI command (may contain secrets - never echoed)
|
|
char reply[160]; // CLI reply budget, same 160 bytes the serial console gets
|
|
};
|
|
|
|
NodePrefs* _prefs;
|
|
MQTTPrefs* _obs;
|
|
Callbacks* _cb;
|
|
const uint8_t* _pub_key;
|
|
const char* _fw_ver;
|
|
const char* _role;
|
|
const char* _board_name;
|
|
|
|
AsyncWebServer* _server = NULL;
|
|
DNSServer* _dns = NULL;
|
|
SemaphoreHandle_t _mux;
|
|
Mode _mode = MODE_OFF;
|
|
bool _stopping = false;
|
|
bool _was_setup_ap = false;
|
|
bool _initial_setup = false;
|
|
char _ap_ssid[33] = {0};
|
|
|
|
// Currently attached session, also used by the display's setup-info poll.
|
|
static WebConfigServer* _active;
|
|
// Process-lifetime listener and route table. Requests retain a pointer to the
|
|
// AsyncWebServer internally until disconnect, so this object is deliberately
|
|
// never deleted during normal firmware operation.
|
|
static AsyncWebServer* _host;
|
|
|
|
// Command batch: filled by async_tcp under _mux, drained by tick().
|
|
volatile BatchState _batch_state = BATCH_IDLE;
|
|
volatile BatchKind _batch_kind = BATCH_CONFIG;
|
|
uint8_t _batch_count = 0;
|
|
uint8_t _batch_next = 0; // drain progress (one command per tick)
|
|
uint32_t _batch_last_cmd = 0;
|
|
bool _batch_reboot = false;
|
|
bool _batch_reboot_armed = false;
|
|
bool _batch_all_ok = true; // every drained command replied "OK" (gates reboot)
|
|
// Client-supplied batch identity, echoed in the 202/result/409 responses so a
|
|
// reused/lost/concurrent result can't be mistaken for this client's own.
|
|
char _batch_reqid[24] = {0};
|
|
BatchEntry _batch[MAX_BATCH];
|
|
|
|
// LAN-mode session (single slot; new login evicts the old session)
|
|
char _session_token[33] = {0};
|
|
uint32_t _session_last_seen = 0;
|
|
uint8_t _login_fails = 0;
|
|
uint32_t _login_lock_until = 0;
|
|
|
|
// Once set (via /api/portal/exit), OS captive probes get native "success"
|
|
// replies so the phone's sign-in sheet can be dismissed without dropping the
|
|
// WiFi, letting the user continue in their real browser. async_tcp-task only.
|
|
volatile bool _captive_release = false;
|
|
|
|
// Save-path diagnostics: 1 Hz serial trace for 60 s after each config POST
|
|
// (AP station count, heap, batch state) to pinpoint client drops on hardware.
|
|
volatile uint32_t _diag_until = 0;
|
|
uint32_t _diag_last = 0;
|
|
|
|
volatile uint32_t _last_activity = 0;
|
|
uint32_t _reboot_at = 0; // 0 = none scheduled
|
|
uint32_t _stop_warn_at = 0;
|
|
bool _stop_warned = false;
|
|
// Number of synchronous route handlers currently using this session. Access
|
|
// is serialized by the file-local route spinlock in WebConfigServer.cpp.
|
|
uint32_t _handler_refs = 0;
|
|
volatile uint32_t _stats_wanted_until = 0;
|
|
uint32_t _stats_built_at = 0;
|
|
char _stats_json[1024] = {0};
|
|
|
|
void createServer();
|
|
void registerRoutes();
|
|
typedef void (WebConfigServer::*RequestHandler)(AsyncWebServerRequest*);
|
|
static void dispatchRequest(AsyncWebServerRequest* req, RequestHandler handler);
|
|
void attachRoutes();
|
|
void detachRoutes();
|
|
uint32_t handlerRefCount() const;
|
|
void drainBatch(uint32_t now);
|
|
void finalizeTeardown();
|
|
bool checkAuth(AsyncWebServerRequest* req);
|
|
static void collectBody(AsyncWebServerRequest* req, uint8_t* data, size_t len,
|
|
size_t index, size_t total);
|
|
|
|
void handleRoot(AsyncWebServerRequest* req);
|
|
void handleStatus(AsyncWebServerRequest* req);
|
|
void handleLogin(AsyncWebServerRequest* req);
|
|
void handleLogout(AsyncWebServerRequest* req);
|
|
void handleConfigGet(AsyncWebServerRequest* req);
|
|
void handleConfigPost(AsyncWebServerRequest* req);
|
|
void handleConfigResult(AsyncWebServerRequest* req);
|
|
void handleCliPost(AsyncWebServerRequest* req);
|
|
void handleCliResult(AsyncWebServerRequest* req);
|
|
void handleStats(AsyncWebServerRequest* req);
|
|
void handleScan(AsyncWebServerRequest* req);
|
|
void handlePresets(AsyncWebServerRequest* req);
|
|
void handleReboot(AsyncWebServerRequest* req);
|
|
void handlePortalExit(AsyncWebServerRequest* req);
|
|
void handleNotFound(AsyncWebServerRequest* req);
|
|
};
|
|
|
|
#endif // ESP_PLATFORM && WITH_MQTT_BRIDGE
|