From a5c9ba3e01c46db701200e6ba1b104f209c1458a Mon Sep 17 00:00:00 2001 From: Jared Dohrman Date: Sun, 9 Aug 2026 16:52:50 +1000 Subject: [PATCH] feat(companion-wifi): recovery AP with TCP rescue CLI when WiFi is down When a companion WiFi device has no WiFi credentials or can't connect for 60s, it broadcasts an 'EastMesh-WiFi' AP (WPA2 password = device pin zero-padded to 8 digits; open if no pin set) with the rescue CLI on TCP port 23, so wifi.ssid/pwd can be fixed without a serial cable. - rescue CLI command handler now writes to any Stream (Serial or the TCP rescue client) - while the AP is up, STA keeps retrying; the AP shuts down automatically once the configured network connects - 'set wifi.ssid/pwd' keeps AP+STA mode during a rescue session so the session isn't dropped mid-config - guard ESP32-only SD/SPI code in ArchiveStorage so non-ESP32 builds of the shared helpers get further Co-Authored-By: Claude Fable 5 --- eastmesh-docs/custom-cli.md | 17 +++ examples/companion_radio/MyMesh.cpp | 209 ++++++++++++++++++++-------- examples/companion_radio/MyMesh.h | 21 +++ examples/companion_radio/main.cpp | 18 +++ src/helpers/ArchiveStorage.cpp | 10 +- 5 files changed, 213 insertions(+), 62 deletions(-) diff --git a/eastmesh-docs/custom-cli.md b/eastmesh-docs/custom-cli.md index c122db45..d661a9c8 100644 --- a/eastmesh-docs/custom-cli.md +++ b/eastmesh-docs/custom-cli.md @@ -260,3 +260,20 @@ Companion Wi-Fi builds also still support the existing rescue commands such as: - `cat ...` - `rm ...` - `reboot` + +### Recovery Access Point + +When a companion Wi-Fi device has no Wi-Fi credentials configured, or cannot connect +to its configured network for 60 seconds, it broadcasts its own `EastMesh-WiFi` +access point so it can be rescued without a serial cable: + +- the AP password is the device's 6-digit pin (`set pin ...`) zero-padded to 8 digits, + e.g. pin `123456` gives Wi-Fi password `00123456` +- if no pin has been set, the AP is **open** — set a pin first if the device is in a + public area +- connect to the AP, then open the rescue CLI with `telnet 192.168.4.1` (or + `nc 192.168.4.1 23`); all rescue commands above are available +- typical recovery: `set wifi.ssid `, `set wifi.pwd `, `reboot` +- while the AP is up the device keeps retrying its configured network; as soon as the + station connection succeeds the recovery AP shuts down automatically +- the MeshCore app can also reach the device through the AP on the usual TCP port diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4fddbff9..6c1ed2ee 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2103,48 +2103,44 @@ void MyMesh::enterCLIRescue() { Serial.println("========= CLI Rescue ========="); } -void MyMesh::checkCLIRescueCmd() { - int len = strlen(cli_command); - while (Serial.available() && len < sizeof(cli_command)-1) { - char c = Serial.read(); - if (c != '\n') { - cli_command[len++] = c; - cli_command[len] = 0; - } - Serial.print(c); // echo - } - if (len == sizeof(cli_command)-1) { // command buffer full - cli_command[sizeof(cli_command)-1] = '\r'; - } +#if defined(ESP32) +void MyMesh::applyStationMode() { +#if defined(WIFI_SSID) + // keep the recovery AP up (AP+STA) so a rescue session isn't dropped mid-config + WiFi.mode(_recovery_ap_active ? WIFI_AP_STA : WIFI_STA); +#else + WiFi.mode(WIFI_STA); +#endif +} +#endif - if (len > 0 && cli_command[len - 1] == '\r') { // received complete line - cli_command[len - 1] = 0; // replace newline with C string null terminator +void MyMesh::handleRescueCommand(char* command, Stream& out) { - if (memcmp(cli_command, "set ", 4) == 0) { - const char* config = &cli_command[4]; + if (memcmp(command, "set ", 4) == 0) { + const char* config = &command[4]; if (memcmp(config, "pin ", 4) == 0) { _prefs.ble_pin = atoi(&config[4]); savePrefs(); - Serial.printf(" > pin is now %06d\n", _prefs.ble_pin); + out.printf(" > pin is now %06d\n", _prefs.ble_pin); #if defined(ESP32) } else if (memcmp(config, "wifi.ssid ", 10) == 0) { StrHelper::strncpy(_prefs.wifi_ssid, &config[10], sizeof(_prefs.wifi_ssid)); savePrefs(); if (_prefs.wifi_ssid[0]) { - WiFi.mode(WIFI_STA); + applyStationMode(); WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave)); WiFi.begin(_prefs.wifi_ssid, _prefs.wifi_pwd); } - Serial.printf(" > wifi.ssid is now: %s\n", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : "-"); + out.printf(" > wifi.ssid is now: %s\n", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : "-"); } else if (memcmp(config, "wifi.pwd ", 9) == 0) { StrHelper::strncpy(_prefs.wifi_pwd, &config[9], sizeof(_prefs.wifi_pwd)); savePrefs(); if (_prefs.wifi_ssid[0]) { - WiFi.mode(WIFI_STA); + applyStationMode(); WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave)); WiFi.begin(_prefs.wifi_ssid, _prefs.wifi_pwd); } - Serial.println(" > wifi.pwd updated"); + out.println(" > wifi.pwd updated"); } else if (memcmp(config, "wifi.powersaving ", 17) == 0) { uint8_t next_mode = 0xFF; if (strcmp(&config[17], "none") == 0) { @@ -2158,67 +2154,67 @@ void MyMesh::checkCLIRescueCmd() { _prefs.wifi_powersave = next_mode; savePrefs(); WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave)); - Serial.printf(" > wifi.powersaving is now: %s\n", getPowerSaveLabel(_prefs.wifi_powersave)); + out.printf(" > wifi.powersaving is now: %s\n", getPowerSaveLabel(_prefs.wifi_powersave)); } else { - Serial.println(" Error: bad wifi.powersaving"); + out.println(" Error: bad wifi.powersaving"); } #endif } else { - Serial.printf(" Error: unknown config: %s\n", config); + out.printf(" Error: unknown config: %s\n", config); } - } else if (strcmp(cli_command, "get wifi.ssid") == 0) { + } else if (strcmp(command, "get wifi.ssid") == 0) { #if defined(ESP32) - Serial.printf(" > %s\n", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : "-"); + out.printf(" > %s\n", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : "-"); #else - Serial.println(" Error: wifi unsupported"); + out.println(" Error: wifi unsupported"); #endif - } else if (strcmp(cli_command, "get wifi.powersaving") == 0) { + } else if (strcmp(command, "get wifi.powersaving") == 0) { #if defined(ESP32) - Serial.printf(" > %s\n", getPowerSaveLabel(_prefs.wifi_powersave)); + out.printf(" > %s\n", getPowerSaveLabel(_prefs.wifi_powersave)); #else - Serial.println(" Error: wifi unsupported"); + out.println(" Error: wifi unsupported"); #endif - } else if (strcmp(cli_command, "get wifi.status") == 0) { + } else if (strcmp(command, "get wifi.status") == 0) { #if defined(ESP32) wl_status_t status = WiFi.status(); if (_prefs.wifi_ssid[0] == 0) { - Serial.println(" > ssid:- status:off code:255 state:unconfigured"); + out.println(" > ssid:- status:off code:255 state:unconfigured"); } else if (status == WL_CONNECTED) { const int rssi_dbm = WiFi.RSSI(); - Serial.printf(" > ssid:%s status:connected code:%d state:%s ip:%s rssi:%d quality:%d%% signal:%s\n", + out.printf(" > ssid:%s status:connected code:%d state:%s ip:%s rssi:%d quality:%d%% signal:%s\n", _prefs.wifi_ssid, static_cast(status), getWifiStateLabel(status), WiFi.localIP().toString().c_str(), rssi_dbm, getWifiQualityPercent(rssi_dbm), getWifiQualityLabel(rssi_dbm)); } else { const char* overall = (status == WL_IDLE_STATUS) ? "connecting" : "disconnected"; - Serial.printf(" > ssid:%s status:%s code:%d state:%s\n", _prefs.wifi_ssid, overall, + out.printf(" > ssid:%s status:%s code:%d state:%s\n", _prefs.wifi_ssid, overall, static_cast(status), getWifiStateLabel(status)); } #else - Serial.println(" Error: wifi unsupported"); + out.println(" Error: wifi unsupported"); #endif - } else if (strcmp(cli_command, "rebuild") == 0) { + } else if (strcmp(command, "rebuild") == 0) { bool success = _store->formatFileSystem(); if (success) { _store->saveMainIdentity(self_id); savePrefs(); saveContacts(); saveChannels(); - Serial.println(" > erase and rebuild done"); + out.println(" > erase and rebuild done"); } else { - Serial.println(" Error: erase failed"); + out.println(" Error: erase failed"); } - } else if (strcmp(cli_command, "erase") == 0) { + } else if (strcmp(command, "erase") == 0) { bool success = _store->formatFileSystem(); if (success) { - Serial.println(" > erase done"); + out.println(" > erase done"); } else { - Serial.println(" Error: erase failed"); + out.println(" Error: erase failed"); } - } else if (memcmp(cli_command, "ls", 2) == 0) { + } else if (memcmp(command, "ls", 2) == 0) { // get path from command e.g: "ls /adafruit" - const char *path = &cli_command[3]; + const char *path = &command[3]; bool is_fs2 = false; if (memcmp(path, "UserData/", 9) == 0) { @@ -2227,7 +2223,7 @@ void MyMesh::checkCLIRescueCmd() { path += 7; // skip "ExtraFS" is_fs2 = true; } - Serial.printf("Listing files in %s\n", path); + out.printf("Listing files in %s\n", path); // log each file and directory File root = _store->openRead(path); @@ -2236,9 +2232,9 @@ void MyMesh::checkCLIRescueCmd() { File file = root.openNextFile(); while (file) { if (file.isDirectory()) { - Serial.printf("[dir] UserData%s/%s\n", path, file.name()); + out.printf("[dir] UserData%s/%s\n", path, file.name()); } else { - Serial.printf("[file] UserData%s/%s (%d bytes)\n", path, file.name(), file.size()); + out.printf("[file] UserData%s/%s (%d bytes)\n", path, file.name(), file.size()); } // move to next file file = root.openNextFile(); @@ -2253,9 +2249,9 @@ void MyMesh::checkCLIRescueCmd() { File file = root2.openNextFile(); while (file) { if (file.isDirectory()) { - Serial.printf("[dir] ExtraFS%s/%s\n", path, file.name()); + out.printf("[dir] ExtraFS%s/%s\n", path, file.name()); } else { - Serial.printf("[file] ExtraFS%s/%s (%d bytes)\n", path, file.name(), file.size()); + out.printf("[file] ExtraFS%s/%s (%d bytes)\n", path, file.name(), file.size()); } // move to next file file = root2.openNextFile(); @@ -2263,10 +2259,10 @@ void MyMesh::checkCLIRescueCmd() { root2.close(); } } - } else if (memcmp(cli_command, "cat", 3) == 0) { + } else if (memcmp(command, "cat", 3) == 0) { // get path from command e.g: "cat /contacts3" - const char *path = &cli_command[4]; + const char *path = &command[4]; bool is_fs2 = false; if (memcmp(path, "UserData/", 9) == 0) { @@ -2275,8 +2271,7 @@ void MyMesh::checkCLIRescueCmd() { path += 7; // skip "ExtraFS" is_fs2 = true; } else { - Serial.println("Invalid path provided, must start with UserData/ or ExtraFS/"); - cli_command[0] = 0; + out.println("Invalid path provided, must start with UserData/ or ExtraFS/"); return; } @@ -2294,19 +2289,19 @@ void MyMesh::checkCLIRescueCmd() { // print hex mesh::Utils::printHex(Serial, buffer, file_size); - Serial.print("\n"); + out.print("\n"); file.close(); } - } else if (memcmp(cli_command, "rm ", 3) == 0) { + } else if (memcmp(command, "rm ", 3) == 0) { // get path from command e.g: "rm /adv_blobs" - const char *path = &cli_command[3]; + const char *path = &command[3]; MESH_DEBUG_PRINTLN("Removing file: %s", path); // ensure path is not empty, or root dir if(!path || strlen(path) == 0 || strcmp(path, "/") == 0){ - Serial.println("Invalid path provided"); + out.println("Invalid path provided"); } else { bool is_fs2 = false; if (memcmp(path, "UserData/", 9) == 0) { @@ -2326,23 +2321,115 @@ void MyMesh::checkCLIRescueCmd() { removed = _store->removeFile(path); } if(removed){ - Serial.println("File removed"); + out.println("File removed"); } else { - Serial.println("Failed to remove file"); + out.println("Failed to remove file"); } } - } else if (strcmp(cli_command, "reboot") == 0) { + } else if (strcmp(command, "reboot") == 0) { board.reboot(); // doesn't return } else { - Serial.println(" Error: unknown command"); + out.println(" Error: unknown command"); } +} + +void MyMesh::checkCLIRescueCmd() { + int len = strlen(cli_command); + while (Serial.available() && len < sizeof(cli_command)-1) { + char c = Serial.read(); + if (c != '\n') { + cli_command[len++] = c; + cli_command[len] = 0; + } + Serial.print(c); // echo + } + if (len == sizeof(cli_command)-1) { // command buffer full + cli_command[sizeof(cli_command)-1] = '\r'; + } + + if (len > 0 && cli_command[len - 1] == '\r') { // received complete line + cli_command[len - 1] = 0; // replace newline with C string null terminator + handleRescueCommand(cli_command, Serial); cli_command[0] = 0; // reset command buffer } } +#if defined(ESP32) && defined(WIFI_SSID) +#ifndef RECOVERY_AP_SSID + #define RECOVERY_AP_SSID "EastMesh-WiFi" +#endif +#ifndef RECOVERY_CLI_PORT + #define RECOVERY_CLI_PORT 23 +#endif + +void MyMesh::startRecoveryAP() { + if (_recovery_ap_active) return; + + char ap_pwd[12]; + const char* pwd = NULL; + if (_prefs.ble_pin != 0) { // WPA2 needs >= 8 chars, so zero-pad the pin + sprintf(ap_pwd, "%08lu", (unsigned long) _prefs.ble_pin); + pwd = ap_pwd; + } + + WiFi.mode(_prefs.wifi_ssid[0] ? WIFI_AP_STA : WIFI_AP); // keep trying STA if creds exist + WiFi.softAP(RECOVERY_AP_SSID, pwd); + + if (_rescue_server == NULL) { + _rescue_server = new WiFiServer(RECOVERY_CLI_PORT); + } + _rescue_server->begin(); + rescue_cmd[0] = 0; + _recovery_ap_active = true; + + Serial.printf("WiFi: recovery AP '%s' up (%s), rescue CLI on %s:%d\n", RECOVERY_AP_SSID, + pwd ? "pwd = pin zero-padded to 8 digits" : "OPEN - use 'set pin' to secure", + WiFi.softAPIP().toString().c_str(), RECOVERY_CLI_PORT); +} + +void MyMesh::stopRecoveryAP() { + if (!_recovery_ap_active) return; + if (_rescue_client) _rescue_client.stop(); + _rescue_server->stop(); + WiFi.softAPdisconnect(true); + WiFi.mode(WIFI_STA); + _recovery_ap_active = false; + Serial.println("WiFi: recovery AP stopped"); +} + +void MyMesh::loopRecoveryAP() { + if (!_recovery_ap_active) return; + + if (!_rescue_client || !_rescue_client.connected()) { + WiFiClient next = _rescue_server->available(); + if (next) { + _rescue_client = next; + rescue_cmd[0] = 0; + _rescue_client.print("========= CLI Rescue =========\r\n"); + } + } + if (_rescue_client && _rescue_client.connected()) { + int len = strlen(rescue_cmd); + while (_rescue_client.available()) { + char c = _rescue_client.read(); + if (c == '\r' || c == '\n') { + if (len > 0) { + handleRescueCommand(rescue_cmd, _rescue_client); + rescue_cmd[0] = 0; + len = 0; + } + } else if (c >= 32 && c < 127 && len < (int) sizeof(rescue_cmd) - 1) { + rescue_cmd[len++] = c; // printable chars only (filters telnet negotiation bytes) + rescue_cmd[len] = 0; + } + } + } +} +#endif + void MyMesh::checkSerialInterface() { size_t len = _serial->checkRecvFrame(cmd_frame); if (len > 0) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index a66cd382..f72fa9db 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -26,6 +26,10 @@ #include "DataStore.h" #include "NodePrefs.h" +#if defined(ESP32) && defined(WIFI_SSID) +#include +#endif + #include #include #include @@ -98,6 +102,13 @@ public: const char* getWifiSSID() const { return _prefs.wifi_ssid; } const char* getWifiPassword() const { return _prefs.wifi_pwd; } uint8_t getWifiPowerSaveMode() const { return _prefs.wifi_powersave; } +#if defined(WIFI_SSID) + // recovery AP: broadcast our own SSID (with rescue CLI on TCP) while STA can't connect + void startRecoveryAP(); + void stopRecoveryAP(); + void loopRecoveryAP(); + bool isRecoveryAPActive() const { return _recovery_ap_active; } +#endif #endif void loop(); @@ -206,8 +217,12 @@ private: } void checkCLIRescueCmd(); + void handleRescueCommand(char* command, Stream& out); void checkSerialInterface(); bool isValidClientRepeatFreq(uint32_t f) const; +#if defined(ESP32) + void applyStationMode(); +#endif // helpers, short-cuts void saveChannels() { _store->saveChannels(this); } @@ -230,6 +245,12 @@ private: bool _cli_rescue; bool send_unscoped; // force un-scoped flood (instead of using send_scope) char cli_command[80]; +#if defined(ESP32) && defined(WIFI_SSID) + bool _recovery_ap_active = false; + WiFiServer* _rescue_server = NULL; + WiFiClient _rescue_client; + char rescue_cmd[80]; +#endif uint8_t app_target_ver; uint8_t *sign_data; uint32_t sign_data_len; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 7af0617a..660fdb40 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -111,6 +111,10 @@ void halt() { #if defined(ESP32) && defined(WIFI_SSID) bool wifi_needs_reconnect = false; unsigned long last_wifi_reconnect_attempt = 0; + unsigned long last_wifi_connected = 0; + #ifndef RECOVERY_AP_TIMEOUT_MS + #define RECOVERY_AP_TIMEOUT_MS 60000 // STA not connected this long -> recovery AP + #endif #endif void setup() { @@ -278,5 +282,19 @@ void loop() { WiFi.reconnect(); last_wifi_reconnect_attempt = millis(); } + + // Recovery AP: broadcast our own SSID (with the rescue CLI on TCP) when STA has no + // credentials or hasn't been able to connect, so wifi.ssid/pwd can be fixed in place + if (the_mesh.isRecoveryAPActive()) { + the_mesh.loopRecoveryAP(); + if (WiFi.status() == WL_CONNECTED) { + the_mesh.stopRecoveryAP(); // STA is up again; back to normal operation + } + } else if (WiFi.status() == WL_CONNECTED) { + last_wifi_connected = millis(); + } else if (the_mesh.getWifiSSID()[0] == 0 || + millis() - last_wifi_connected > RECOVERY_AP_TIMEOUT_MS) { + the_mesh.startRecoveryAP(); + } #endif } diff --git a/src/helpers/ArchiveStorage.cpp b/src/helpers/ArchiveStorage.cpp index 9e455385..b09910a7 100644 --- a/src/helpers/ArchiveStorage.cpp +++ b/src/helpers/ArchiveStorage.cpp @@ -105,8 +105,14 @@ SPIClass* getBoardSharedArchiveSPI() { } #endif +#if defined(ESP32) + #define ARCHIVE_DEFAULT_SPI_BUS HSPI +#else + #define ARCHIVE_DEFAULT_SPI_BUS 0 +#endif + ArchiveStorage::ArchiveStorage() - : _attempted(false), _mounted(false), _mount_failed(false), _supported(false), _card_type(0), _spi_bus(HSPI), + : _attempted(false), _mounted(false), _mount_failed(false), _supported(false), _card_type(0), _spi_bus(ARCHIVE_DEFAULT_SPI_BUS), _cs_pin(0xFF), _sck_pin(0xFF), _miso_pin(0xFF), _mosi_pin(0xFF), _card_size_bytes(0), _total_bytes(0), _used_bytes(0) #if defined(ESP32) @@ -115,6 +121,7 @@ ArchiveStorage::ArchiveStorage() { } +#if defined(ESP32) namespace { bool mountArchiveSd(SPIClass* spi, @@ -137,6 +144,7 @@ bool mountArchiveSd(SPIClass* spi, } } // namespace +#endif void ArchiveStorage::begin() { if (_attempted) {