Share WiFi OTA seeding across full ESP32 builds

This commit is contained in:
mikecarper committed 2026-07-29 01:51:01 -07:00
1 parent 3a5f330727
commit e6abf3ea7d
23 files changed
+497 -95

No files matched your search

+7
View File
@@ -4,6 +4,9 @@
#include "helpers/ota/OtaContext.h" // OTA mesh-integration is centralized here so every role gets it
#include "helpers/ota/OtaProtocol.h" // decode_adv -> the `ota neighbors` discovery table
#include "helpers/ota/OtaSelf.h" // ota_self_firmware -> auto-advertise our own image
#if defined(ESP32_PLATFORM) && (defined(WIFI_OTA_SEEDER) || defined(WIFI_SSID))
#include "helpers/esp32/WiFiOtaSeeder.h"
#endif
#ifndef OTA_ANNOUNCE_BOOT_MS
#define OTA_ANNOUNCE_BOOT_MS 8000UL // first self-advert ~8 s after boot (settled, but quick to discover)
#endif
@@ -219,6 +222,10 @@ void Mesh::begin() {
void Mesh::loop() {
Dispatcher::loop();
serviceLoopMaintenance();
#if defined(ENABLE_OTA) && defined(ESP32_PLATFORM) && \
(defined(WIFI_OTA_SEEDER) || defined(WIFI_SSID))
ota::WiFiOtaSeeder::loop();
#endif
}
void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
+17
View File
@@ -4066,6 +4066,23 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
#endif
// Observer/MQTT/WiFi/timezone/alert/SNMP commands live in CommonCLI_Observer.cpp.
if (handleObserverGetCmd(sender_timestamp, config, reply)) return;
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
// Non-MQTT FULL repeater/room-server builds keep WiFi only for WebConfig.
// Their credentials live in the standalone WebConfig NVS namespace rather
// than MQTTPrefs, so expose the useful read-only status through the role.
if (strcmp(config, "wifi.ssid") == 0) {
if (!_callbacks->getWiFiSSID(reply)) {
strcpy(reply, "Error: WiFi SSID unavailable on this build");
}
return;
}
if (strcmp(config, "wifi.status") == 0) {
if (!_callbacks->getWiFiStatus(reply)) {
strcpy(reply, "Error: WiFi status unavailable on this build");
}
return;
}
#endif
#if defined(PORTABLE_ESP32_RADIO_CLI)
if (strcmp(config, "int.thresh") == 0) {
sprintf(reply, "> %d", (uint32_t)_prefs->interference_threshold);
+8
View File
@@ -394,6 +394,14 @@ public:
(void)reply;
return false;
};
virtual bool getWiFiSSID(char* reply) const {
(void)reply;
return false;
};
virtual bool getWiFiStatus(char* reply) const {
(void)reply;
return false;
};
virtual int getQueueSize() {
return 0; // no op by default
+24
View File
@@ -0,0 +1,24 @@
#pragma once
namespace WiFiOtaSeederPolicy {
enum class ListenerAction {
Keep,
Start,
Stop,
};
inline ListenerAction listenerAction(bool network_ready, bool listening) {
if (network_ready) return listening ? ListenerAction::Keep : ListenerAction::Start;
return listening ? ListenerAction::Stop : ListenerAction::Keep;
}
inline bool canAttachTcpFolder(bool folder_active, bool tcp_folder_attached) {
return !folder_active || tcp_folder_attached;
}
inline bool tcpFolderWasDetached(bool tcp_folder_attached, bool folder_active) {
return tcp_folder_attached && !folder_active;
}
} // namespace WiFiOtaSeederPolicy
+80
View File
@@ -151,6 +151,86 @@ bool WebConfigServer::saveStandaloneWiFi(const char* ssid, const char* password,
return ok;
}
bool WebConfigServer::formatWiFiSSID(char* reply, size_t reply_len) {
if (!reply || reply_len == 0) return false;
char ssid[32] = "";
char password[64] = "";
uint8_t power_save = 1;
bool configured = loadStandaloneWiFi(
ssid, sizeof(ssid), password, sizeof(password), &power_save);
if (_active && _active->_wifi_ssid[0]) {
strncpy(ssid, _active->_wifi_ssid, sizeof(ssid) - 1);
ssid[sizeof(ssid) - 1] = 0;
configured = true;
}
snprintf(reply, reply_len, configured ? "> %s" : "> (not configured)", ssid);
return true;
}
bool WebConfigServer::formatWiFiStatus(char* reply, size_t reply_len) {
if (!reply || reply_len == 0) return false;
char ssid[32] = "";
char password[64] = "";
uint8_t power_save = 1;
bool configured = loadStandaloneWiFi(
ssid, sizeof(ssid), password, sizeof(password), &power_save);
WebConfigServer* active = _active;
if (active && active->_wifi_ssid[0]) {
strncpy(ssid, active->_wifi_ssid, sizeof(ssid) - 1);
ssid[sizeof(ssid) - 1] = 0;
configured = true;
}
if (active && active->_mode == MODE_SETUP) {
char ap_ssid[33] = "";
char ip[16] = "";
getSetupInfo(ap_ssid, sizeof(ap_ssid), ip, sizeof(ip));
snprintf(reply, reply_len, "> setup AP, SSID: %s, IP: %s", ap_ssid, ip);
return true;
}
if (active && active->_mode == MODE_CONNECTING) {
snprintf(reply, reply_len, "> connecting, SSID: %s",
configured ? ssid : "(not configured)");
return true;
}
const wl_status_t status = WiFi.status();
if (status == WL_CONNECTED) {
snprintf(reply, reply_len, "> connected, SSID: %s, IP: %s, RSSI: %d dBm",
configured ? ssid : WiFi.SSID().c_str(),
WiFi.localIP().toString().c_str(), WiFi.RSSI());
return true;
}
if (!configured) {
strcpy(reply, "> not configured; run 'start webconfig'");
return true;
}
switch (status) {
case WL_NO_SSID_AVAIL:
snprintf(reply, reply_len, "> failed, SSID not found: %s", ssid);
break;
case WL_CONNECT_FAILED:
snprintf(reply, reply_len, "> failed to connect, SSID: %s", ssid);
break;
case WL_CONNECTION_LOST:
snprintf(reply, reply_len, "> connection lost, SSID: %s", ssid);
break;
default:
if (active && active->_mode == MODE_LAN) {
snprintf(reply, reply_len, "> disconnected, SSID: %s", ssid);
} else {
snprintf(reply, reply_len,
"> off, configured SSID: %s; run 'start webconfig'", ssid);
}
break;
}
return true;
}
void WebConfigServer::requestToggleFromButton() {
webconfig_button_toggle_requested = true;
}
+2
View File
@@ -131,6 +131,8 @@ public:
uint8_t* power_save = NULL);
static bool saveStandaloneWiFi(const char* ssid, const char* password,
uint8_t power_save);
static bool formatWiFiSSID(char* reply, size_t reply_len);
static bool formatWiFiStatus(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 /
+127
View File
@@ -0,0 +1,127 @@
#include "WiFiOtaSeeder.h"
#if defined(ESP32_PLATFORM) && defined(ENABLE_OTA) && \
(defined(WIFI_OTA_SEEDER) || defined(WIFI_SSID))
#include <Arduino.h>
#include <WiFi.h>
#include <helpers/WiFiOtaSeederPolicy.h>
#include <helpers/ota/FolderMotaStore.h>
#include <helpers/ota/MotaSourceSerial.h>
#include <helpers/ota/OtaContext.h>
namespace mesh {
namespace ota {
namespace {
WiFiServer seeder_server(OTA_SEEDER_TCP_PORT);
WiFiClient seeder_client;
SerialMotaSource seeder_source(seeder_client, 3000);
FolderMotaStore folder_store(seeder_client, 3000);
bool listener_active = false;
bool tcp_folder_attached = false;
bool networkReady() {
if (WiFi.status() == WL_CONNECTED) return true;
const wifi_mode_t mode = WiFi.getMode();
if (mode != WIFI_AP && mode != WIFI_AP_STA) return false;
return static_cast<uint32_t>(WiFi.softAPIP()) != 0;
}
void detachTcpFolder() {
if (!tcp_folder_attached) return;
OtaContext& context = ota_ctx();
if (context.folderLink() == OtaContext::FOLDER_LINK_TCP) {
context.detach_folder();
context.clear_folder_dest();
context.manager.announce();
}
tcp_folder_attached = false;
}
void stopClient() {
detachTcpFolder();
if (seeder_client) seeder_client.stop();
}
} // namespace
void WiFiOtaSeeder::loop() {
using WiFiOtaSeederPolicy::ListenerAction;
switch (WiFiOtaSeederPolicy::listenerAction(networkReady(), listener_active)) {
case ListenerAction::Start:
seeder_server.begin();
listener_active = true;
Serial.printf("OTA seeder listening on :%u (motatool serve --tcp)\n",
static_cast<unsigned>(OTA_SEEDER_TCP_PORT));
break;
case ListenerAction::Stop:
stop();
return;
case ListenerAction::Keep:
break;
}
if (!listener_active) return;
OtaContext& context = ota_ctx();
if (WiFiOtaSeederPolicy::tcpFolderWasDetached(tcp_folder_attached,
context.folder_active)) {
if (seeder_client) seeder_client.stop();
context.clear_folder_dest();
tcp_folder_attached = false;
}
if (seeder_client && seeder_client.connected()) return;
stopClient();
WiFiClient incoming = seeder_server.available();
if (!incoming) return;
if (!WiFiOtaSeederPolicy::canAttachTcpFolder(context.folder_active,
tcp_folder_attached)) {
incoming.stop();
Serial.println("OTA seeder rejected TCP client: another folder link is active");
return;
}
seeder_client = incoming;
char attach_reply[120];
if (!context.attach_folder_source(&seeder_source, OtaContext::FOLDER_LINK_TCP,
"tcp", attach_reply, sizeof(attach_reply))) {
seeder_client.stop();
Serial.printf("OTA seeder rejected TCP client: %s\n", attach_reply);
return;
}
tcp_folder_attached = true;
char link_info[24];
snprintf(link_info, sizeof(link_info), "tcp %s",
seeder_client.remoteIP().toString().c_str());
context.set_folder_dest(&folder_store, link_info);
if (context.manager.fetchState() == OtaManager::PAUSED) {
context.manager.resumeStaged(nullptr);
}
context.manager.announce();
Serial.printf("OTA seeder client connected (%s)\n", link_info);
}
void WiFiOtaSeeder::stop() {
stopClient();
if (listener_active) seeder_server.end();
listener_active = false;
}
bool WiFiOtaSeeder::isListening() {
return listener_active;
}
bool WiFiOtaSeeder::isAttached() {
return tcp_folder_attached;
}
} // namespace ota
} // namespace mesh
#endif
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#if defined(ESP32_PLATFORM) && defined(ENABLE_OTA) && \
(defined(WIFI_OTA_SEEDER) || defined(WIFI_SSID))
#include <stdint.h>
#ifndef OTA_SEEDER_TCP_PORT
#define OTA_SEEDER_TCP_PORT 5001
#endif
namespace mesh {
namespace ota {
class WiFiOtaSeeder {
public:
static void loop();
static void stop();
static bool isListening();
static bool isAttached();
static uint16_t port() { return OTA_SEEDER_TCP_PORT; }
};
} // namespace ota
} // namespace mesh
#endif
+41 -7
View File
@@ -158,6 +158,38 @@ struct OtaContext {
uint32_t session_started_ms = 0; // when the fetch session last left IDLE (for the age display)
uint8_t prev_fstate = OtaManager::IDLE;
bool folder_active = false; // an external `.mota` folder is attached + being relayed
enum FolderLink : uint8_t {
FOLDER_LINK_NONE = 0,
FOLDER_LINK_SERIAL,
FOLDER_LINK_TCP,
};
FolderLink folderLink() const { return _folder_link; }
bool attach_folder_source(MotaSource* source, FolderLink link, const char* label,
char* msg, size_t cap) {
if (!msg || cap == 0) return false;
if (!source || link == FOLDER_LINK_NONE) {
strncpy(msg, "ERR invalid folder source", cap);
msg[cap - 1] = 0;
return false;
}
if (folder_active && _folder_link != link) {
snprintf(msg, cap, "ERR folder already attached via %s",
_folder_link == FOLDER_LINK_TCP ? "tcp" : "serial");
return false;
}
manager.clear_sources();
if (!manager.add_source(source)) {
strncpy(msg, "ERR no free source slot", cap);
msg[cap - 1] = 0;
return false;
}
folder_active = true;
_folder_link = link;
snprintf(msg, cap, "OK folder attached (%s) - serving %u mOTA total (own fw + folder)",
label ? label : "external", (unsigned)manager.servedCount());
return true;
}
// Attach/detach an external folder of `.mota` served by a host daemon over the seeder UART (the node
// then advertises + relays them alongside its own fw). Only built when OTA_FOLDER_SERIAL is configured.
@@ -167,15 +199,14 @@ struct OtaContext {
#ifdef OTA_FOLDER_SERIAL_BEGIN
OTA_FOLDER_SERIAL_STREAM.begin(OTA_FOLDER_SERIAL_BAUD); // dedicated UART; console is already up
#endif
manager.clear_sources(); // idempotent re-attach
if (!manager.add_source(&src)) { strncpy(msg, "ERR no free source slot", cap); return false; }
folder_active = true;
snprintf(msg, cap, "OK folder attached (serial) - serving %u mOTA total (own fw + folder)",
(unsigned)manager.servedCount());
return true;
return attach_folder_source(&src, FOLDER_LINK_SERIAL, "serial", msg, cap);
}
#endif
void detach_folder() { manager.clear_sources(); folder_active = false; }
void detach_folder() {
manager.clear_sources();
folder_active = false;
_folder_link = FOLDER_LINK_NONE;
}
void track_session(uint8_t fstate, uint32_t now) { // stamp the session start (age display)
if (fstate != prev_fstate) {
@@ -206,6 +237,9 @@ struct OtaContext {
#endif
manager.set_fetch_store(&fetch_store);
}
private:
FolderLink _folder_link = FOLDER_LINK_NONE;
};
OtaContext& ota_ctx(); // process-wide singleton