#pragma once // Shared browser configuration portal for ESP32 infrastructure and WiFi // companion builds. The UI removes MQTT controls when no bridge is present. // // Two modes: // - SETUP: admin-password SoftAP + captive portal, raised on first boot // when no WiFi is configured (wifi_ssid empty), or manually via // `start webconfig ap`. A credential save joins in AP+STA mode, reports the // assigned LAN IP, then reboots; the AP auto-stops after an idle timeout. // Legacy short/non-ASCII admin credentials use open WiFi plus HTTP login. // - LAN: bound to an existing or portal-owned STA connection. Infrastructure // roles use the committed node admin password. Explicit legacy passwordless // configurations retain their local no-password policy. // // Concurrency model: AsyncWebServer handlers run on the async_tcp task and // must never touch the CLI, prefs persistence, or the radio. Config writes // and browser-terminal commands are marshaled to tick() - called from // MyMesh::loop() on the Arduino loop task - and run through the existing CLI // handlers there. Request state is guarded by _mux; authentication uses the // separately published committed credential generation, never temporary prefs. // // The master switch and standalone WiFi credentials use independent NVS // namespaces, so role-specific prefs-file layouts remain untouched. #if defined(ESP_PLATFORM) && !defined(WEBCONFIG_DISABLED) #define WITH_WEBCONFIG 1 #include #include #include #include #include #include #include #include #include #include #include #include #include #include "WebConfigJsonAllocator.h" class AsyncWebServer; class AsyncWebServerRequest; class DNSServer; class WebTerminalStream; #ifndef WEBCONFIG_AP_IDLE_TIMEOUT_MS #if defined(MESHCORE_EXPANDED_PARTITION_PROFILE) #define WEBCONFIG_AP_IDLE_TIMEOUT_MS WebConfigBatch::kFullSetupApWindowMs #else #define WEBCONFIG_AP_IDLE_TIMEOUT_MS (10UL * 60UL * 1000UL) #endif #endif #ifndef WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS #if defined(MESHCORE_EXPANDED_PARTITION_PROFILE) #define WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS WebConfigBatch::kFullSetupApWindowMs #else #define WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS 0UL #endif #endif #ifndef WEBCONFIG_SESSION_TTL_MS #define WEBCONFIG_SESSION_TTL_MS (20UL * 60UL * 1000UL) #endif #ifndef WEBCONFIG_AP_PREFIX #if defined(MESHCORE_HAS_REAL_DISPLAY) // Keeps an open-network WiFi QR in Version 1 on 64-pixel-high screens. #define WEBCONFIG_AP_PREFIX "MC" #else #define WEBCONFIG_AP_PREFIX "MeshCore-Setup" #endif #endif class WebConfigServer { using DynamicJsonDocument = mesh::webconfig::DynamicJsonDocument; template using StaticJsonDocument = mesh::webconfig::StaticJsonDocument; public: enum Mode : uint8_t { MODE_OFF = 0, MODE_SETUP, MODE_CONNECTING, MODE_LAN }; enum Capability : uint32_t { CAP_LOCATION = 1UL << 0, CAP_AIRTIME = 1UL << 1, CAP_RX_DELAY = 1UL << 2, CAP_CAD = 1UL << 3, CAP_RX_GAIN = 1UL << 4, CAP_REPEAT = 1UL << 5, CAP_ADVERT = 1UL << 6, CAP_FLOOD = 1UL << 7, CAP_LOOP = 1UL << 8, CAP_TX_DELAY = 1UL << 9, CAP_WIFI_POWER_SAVE = 1UL << 10, CAP_FEM_RX_GAIN = 1UL << 11, CAP_RX_POWER_SAVING = 1UL << 12, CAP_POWER_SAVING = 1UL << 13, CAP_BLUETOOTH_NAME = 1UL << 14, CAP_ESPNOW_CHANNEL = 1UL << 15, CAP_BLUETOOTH_MAC = 1UL << 16, CAP_BLUETOOTH_STEALTH = 1UL << 17, CAP_LOST_REPLY = 1UL << 18, CAP_DELAYS = CAP_RX_DELAY | CAP_TX_DELAY, }; // Role-neutral snapshot. Infrastructure and companion builds have different // persisted NodePrefs layouts, so the portal never casts either one or // assumes their member offsets match. struct NodeSnapshot { char name[32]; char bluetooth_name[32]; // empty = transport's default device name char bluetooth_mac[24]; // empty = chipset factory address bool bluetooth_stealth; // independent of the address policy uint8_t lost_reply; // 0=off, 1=no, 2=yes (Companion only) char admin_password[32]; // empty = trusted LAN/no login prompt double lat; double lon; float freq; float bw; float airtime_factor; float rx_delay; float tx_delay; int8_t tx_power; uint8_t sf; uint8_t cr; uint8_t cad; uint8_t rx_gain; uint8_t fem_rx_gain; uint8_t rx_ps_enabled; uint8_t rx_ps_level; uint8_t rx_ps_preamble; uint32_t rx_ps_rx_us; uint32_t rx_ps_sleep_us; uint8_t power_saving; uint16_t cpu_freq_mhz; // 0 = automatic device policy uint16_t cpu_actual_mhz; uint8_t cpu_supported_mask; // bits 0/1/2 = 80/160/240 MHz bool display_rotation_supported; uint16_t display_rotation_degrees; bool mqtt_enabled; uint8_t repeat; uint16_t advert_interval; uint8_t flood_advert_interval; uint8_t flood_max; uint8_t flood_max_advert; uint8_t flood_max_unscoped; uint8_t loop_detect; uint32_t capabilities; }; class Callbacks { public: virtual void getNodeSnapshot(NodeSnapshot& snapshot) = 0; // Run one CLI command. Called from tick() only (loop task); reply is 160 bytes. virtual void execCommand(char* cmd, char* reply) = 0; // Authenticated LAN commands have local-console access, like a direct // USB/Ethernet connection. LoRa access is handled separately by each role. virtual bool supportsCliTerminal() const { return false; } virtual void execAdminCommand(char* cmd, char* reply) { execCommand(cmd, reply); } // Companion's browser uses the same stateful Stream terminal as USB/TCP. // All session callbacks run on the mesh loop, never the HTTP task. virtual bool supportsStreamTerminal() const { return false; } virtual bool beginStreamTerminal(Stream& output) { return false; } virtual bool ownsStreamTerminal(const Stream& output) const { return false; } virtual void runStreamTerminal(char* command) {} virtual bool streamTerminalBusy() const { return false; } virtual void endStreamTerminal(Stream& output) {} 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 (server + DNS freed, WiFi mode restored). virtual void onWebConfigStopped() {} virtual bool supportsRoomService() const { return false; } // The role owns its page resource. Keeping it out of this shared server // lets repeaters/companions omit the Room UI without disabling a feature. virtual const uint8_t* roomPageGzip(size_t& length) const { length = 0; return nullptr; } // Input may be parsed in place. Authentication, storage and radio work // happen only on tick(), never on async_tcp. virtual void processRoomRequest(const uint8_t* token, uint32_t sequence, char* request, char* response, size_t capacity) {} virtual bool roomRequestAuthenticated() const { return false; } }; // mqtt_prefs is an MQTTPrefs* on WITH_MQTT_BRIDGE builds and NULL otherwise. // owns_wifi is true when the portal itself brought up STA/AP (normal // repeater/room-server builds), false when the companion/MQTT runtime owns it. WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool owns_wifi, const uint8_t* pub_key, const char* fw_ver, const char* build_date, const char* role, const char* board_name, bool standalone_wifi = false); ~WebConfigServer(); // Infrastructure roles refresh this from actual MQTT/UDP station owners. // A compiled-in but stopped/unconfigured bridge cannot reconnect WiFi. // Companion runtimes retain their own station ownership. Loop task only. void updateWiFiOwnership(bool owns_wifi) { _runtime_wifi_ownership_managed = true; if (_owns_wifi == owns_wifi) return; _owns_wifi = owns_wifi; // Discard only our attempt state. Never disconnect another owner's STA. _connect_deadline = 0; _setup_reconnect_in_progress = false; _setup_reconnect_deadline = 0; } // A station-only owner may reclaim an idle automatic recovery AP. Explicit // setup APs and active browser/OTA work keep their link until released. // Returns true when that owner may manage STA. Loop task only. bool prepareStationOwner(); // 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(); // Persistent master switch and standalone WiFi credentials use separate NVS // namespaces. This avoids changing NodePrefs or MQTTPrefs file layouts. static bool loadEnabled(bool default_value = false); static bool saveEnabled(bool enabled); static bool loadCliEnabled(bool default_value = true); static bool saveCliEnabled(bool enabled); static bool loadStandaloneWiFi(char* ssid, size_t ssid_len, char* password, size_t password_len, uint8_t* power_save = NULL, const void* legacy_prefs = NULL); static bool hasConfiguredWiFi(const void* legacy_prefs = NULL); static mesh::wifi::CredentialState resolveWiFi(mesh::wifi::Credentials& out, const void* legacy_prefs = NULL); static bool saveStandaloneWiFi(const char* ssid, const char* password, uint8_t power_save); static bool setStandaloneWiFiSSID(const char* value, char* reply, size_t reply_len, const void* legacy_prefs = NULL); static bool setStandaloneWiFiPassword(const char* value, char* reply, size_t reply_len, const void* legacy_prefs = NULL); static bool setStandaloneWiFiPowerSave(const char* value, char* reply, size_t reply_len, const void* legacy_prefs = NULL); static bool setWiFiCliEnabled(const char* value, char* reply, size_t reply_len); static bool formatWiFiSSID(char* reply, size_t reply_len, const void* legacy_prefs = NULL); static bool formatWiFiPassword(char* reply, size_t reply_len, const void* legacy_prefs = NULL); static bool formatWiFiStatus( char* reply, size_t reply_len, const mesh::wifi::CompanionWiFiRuntimeState* companion_runtime = NULL, const void* legacy_prefs = NULL); static bool formatWiFiPowerSave(char* reply, size_t reply_len, const void* legacy_prefs = NULL); static bool formatWiFiCliStatus(char* reply, size_t reply_len); // UI tasks use an otherwise-unused multi-click gesture without reaching into // MyMesh directly. The mesh loop consumes the request and performs the NVS / // WiFi transition on its own task. static void requestToggleFromButton(); static bool takeButtonToggleRequest(); bool startSetupMode(char reply[]); // open SoftAP + DNS captive portal bool startLanMode(char reply[]); // bind to existing STA connection bool startAutoMode(char reply[]); // use saved WiFi, or setup AP when absent // Accept a start through asynchronous teardown or a transient SDK failure. bool requestStart(bool force_ap, char reply[]); void requestRestart(); // coalesce saved credential changes bool isStartPending() const { return _start_mode != MODE_OFF; } bool reloadStandaloneWiFi(); void requestStop(); // stop listening and detach this session // Release HTTP/DNS for the updater without delayed teardown taking its // WiFi down. Existing HTTP request objects still drain through tick(). bool stopForOTA(char reply[]); void tick(uint32_t now); // call every loop iteration // A listening LAN portal is not itself a reason to keep a failed background // network awake. Explicit starts and real browser activity get a bounded // window; AP setup and in-flight work retain the radio until they complete. void requestNetworkAccess(); bool isNetworkForegroundActive(uint32_t now) const; Mode mode() const { return _mode; } bool isRunning() const { return _mode != MODE_OFF; } bool isStopping() const { return _stopping; } bool isSavedWiFiRecoveryActive() const { return _mode == MODE_SETUP && _retry_saved_wifi_in_setup && !_stopping; } private: static const int MAX_BATCH = WebConfigBatch::kMaxBatch; static const size_t MAX_BODY = 4096; 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 }; Callbacks* _cb; void* _mqtt_prefs; bool _owns_wifi; // Legacy Companion/manual portals preserve their original start behavior; // infrastructure opts into worker ownership through updateWiFiOwnership. bool _runtime_wifi_ownership_managed = false; // Credential storage is fixed at construction, independently of which // runtime currently owns the station. Async UI handlers read this flag. const bool _standalone_wifi; const uint8_t* _pub_key; const char* _fw_ver; const char* _build_date; const char* _role; const char* _board_name; AsyncWebServer* _server = NULL; DNSServer* _dns = NULL; SemaphoreHandle_t _mux; Mode _mode = MODE_OFF; bool _stopping = false; Mode _start_mode = MODE_OFF; uint32_t _start_retry_at = 0; uint32_t _start_retry_delay = 5000; bool _start_retryable = false; bool _keep_wifi_on_stop = false; bool _was_setup_ap = false; bool _initial_setup = false; bool _setup_provisioning_privilege = false; uint32_t _setup_started_at = 0; uint32_t _connect_deadline = 0; char _wifi_ssid[32] = {0}; // 63-character passphrase + NUL, or 64-hex raw WPA/WPA2 PSK + NUL. char _wifi_password[65] = {0}; uint8_t _wifi_power_save = mesh::wifi::kDefaultPowerSave; bool _cli_enabled = true; // A `password` command has succeeded this session. Lets the CLI satisfy the // initial-setup invariant across separate submissions; the form batch always // sends the password with the rest, so it never needed the memory. bool _admin_pwd_set = false; char _ap_ssid[33] = {0}; uint32_t _ap_auth_generation = 0; uint32_t _ap_rekey_generation = 0; uint32_t _ap_rekey_after = 0; uint32_t _ap_rekey_deadline = 0; WiFiReconnectPolicy::Tracker _wifi_reconnect_tracker; bool _retry_saved_wifi_in_setup = false; bool _setup_reconnect_in_progress = false; uint32_t _setup_reconnect_deadline = 0; // Currently attached session, also used by the display's setup-info poll. static WebConfigServer* _active; // Process-lifetime listener and route table. Async requests retain their // server pointer until disconnect, so this object is not deleted on stop. 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; bool _batch_started = false; char _batch_reqid[24] = {0}; uint32_t _batch_auth_generation = 0; char _batch_owner_token[33] = {0}; bool _batch_reauth_required = false; bool _batch_reauth_delivered = false; uint32_t _batch_reauth_until = 0; bool _standalone_wifi_dirty = false; bool _setup_wifi_handoff_pending = false; uint32_t _setup_wifi_handoff_deadline = 0; char _setup_wifi_handoff_ip[16] = {0}; BatchEntry _batch[MAX_BATCH]; WebTerminalStream* _terminal = NULL; mesh::RoomWebMailbox* _room_mailbox = NULL; // Browser login session, bound to the committed credential generation. // One slot: a new login evicts the old session in LAN or recovery AP mode. char _session_token[33] = {0}; uint32_t _session_last_seen = 0; uint32_t _session_generation = 0; uint32_t _terminal_auth_generation = 0; WebConfigLoginThrottle::Tracker _login_throttle; // 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; volatile uint32_t _network_activity_until = 0; uint32_t _reboot_at = 0; // 0 = none scheduled uint32_t _stop_warn_at = 0; bool _stop_warned = false; uint32_t _handler_refs = 0; volatile uint32_t _stats_wanted_until = 0; uint32_t _stats_built_at = 0; char _stats_json[1024] = {0}; uint8_t _board_cmds = 0; bool _board_cmds_probed = false; void createServer(); void noteNetworkActivity(); void refreshNetworkPowerHold(uint32_t now); bool beginSavedStation(uint32_t now); void serviceStartRetry(uint32_t now); 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 probeBoardCommands(); void serviceTerminal(uint32_t now); void serviceRoom(uint32_t now); void closeTerminal(); void serviceSetupWiFiHandoff(uint32_t now); void serviceSetupPassword(uint32_t now); void finishBatch(uint32_t now); void finalizeTeardown(); void initializeManagementPassword(); void clearSessionLocked(); bool checkAuth(AsyncWebServerRequest* req); bool checkAuthLocked(AsyncWebServerRequest* req, uint32_t* generation = nullptr); bool resultReceiptAllowedLocked(AsyncWebServerRequest* req, const char* reqid, BatchKind kind, uint32_t now); void beginBatchAuthorizationLocked(uint32_t generation); void cancelStaleBatchLocked(uint32_t now); static void collectBody(AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total); void handleRoot(AsyncWebServerRequest* req); void handleUi(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 handleTerminalPost(AsyncWebServerRequest* req); void handleTerminalOutput(AsyncWebServerRequest* req); bool checkTerminalAccess(AsyncWebServerRequest* req); void handleStats(AsyncWebServerRequest* req); void handleScan(AsyncWebServerRequest* req); void handlePresets(AsyncWebServerRequest* req); void handleReboot(AsyncWebServerRequest* req); void handlePortalExit(AsyncWebServerRequest* req); void handleRoomPage(AsyncWebServerRequest* req); void handleRoomPost(AsyncWebServerRequest* req); void handleRoomResult(AsyncWebServerRequest* req); void handleNotFound(AsyncWebServerRequest* req); }; #endif // ESP_PLATFORM && !WEBCONFIG_DISABLED