Share WiFi OTA seeding across full ESP32 builds

This commit is contained in:
mikecarper
2026-07-31 01:19:17 -07:00
parent e830726554
commit 8b89fe3657
23 changed files with 497 additions and 95 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ MeshCore is a lightweight, portable C++ library that enables multi-hop packet ro
MeshCore now supports a range of LoRa devices, allowing for easy flashing without the need to compile firmware manually. Users can flash a pre-built binary using tools like Adafruit ESPTool and interact with the network through a serial console.
MeshCore provides the ability to create wireless mesh networks, similar to Meshtastic and Reticulum but with a focus on lightweight multi-hop packet routing for embedded projects. Unlike Meshtastic, which is tailored for casual LoRa communication, or Reticulum, which offers advanced networking, MeshCore balances simplicity with scalability, making it ideal for custom embedded solutions, where devices (nodes) can communicate over long distances by relaying messages through intermediate nodes. This is especially useful in off-grid, emergency, or tactical situations where traditional communication infrastructure is unavailable.
> **WiFi and MQTT** - See [WiFi and MQTT by Firmware Type](./WiFi.md) for the
> **WiFi and MQTT** - See [WiFi and MQTT by Firmware Type](./docs/WiFi.md) for the
> role/build matrix and setup overview. For the complete MQTT command and
> broker reference, see the [MQTT Implementation Guide](./MQTT_IMPLEMENTATION.md).
+1 -1
View File
@@ -1809,7 +1809,7 @@ apply_esp32_full_size_profile() {
# The FULL artifact uses expanded dual-OTA slots, so restore features that
# target or portable profiles disabled only to save application space.
append_platformio_build_unflags "-DWEBCONFIG_DISABLED=1"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UWEBCONFIG_DISABLED"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UWEBCONFIG_DISABLED -DWIFI_OTA_SEEDER=1"
# Restore the full ElegantOTA implementation only when the target already
# declares its dependency. Some ESP32-C6 targets intentionally have no
+56 -13
View File
@@ -20,17 +20,18 @@ to MQTT unless its target name also contains `mqtt`.
| Firmware target or role | Infrastructure WiFi | MQTT | What WiFi does |
|---|---:|---:|---|
| `*_repeater` | Build-dependent on ESP32 | No | WebConfig/WiFi OTA only when those features are compiled in |
| `*_repeater` | Build-dependent on ESP32 | No | FULL builds provide WebConfig, browser OTA, and the TCP 5001 LoRa-OTA seeder while WiFi is active |
| `*_repeater_observer_mqtt` | Yes | Yes | Uplinks heard and selected transmitted LoRa packets; repeating remains enabled by default |
| `*_room_server` | Build-dependent on ESP32 | No | WebConfig/WiFi OTA only when those features are compiled in |
| `*_room_server` | Build-dependent on ESP32 | No | FULL builds provide WebConfig, browser OTA, and the TCP 5001 LoRa-OTA seeder while WiFi is active |
| `*_room_server_observer_mqtt` | Yes | Yes | Runs the room server and uplinks radio traffic |
| `*_companion_radio_wifi` | Yes | No | Exposes the MeshCore companion protocol on TCP port 5000 |
| `*_companion_radio_wifi_mqtt` | Yes | Yes | Runs both the TCP companion interface and the MQTT uplink |
| USB, BLE, or serial companion | No | No | Uses the transport named by the target instead |
| `*_repeater_bridge_espnow` | No LAN connection | No | Uses ESP-NOW to bridge packets between ESP32 devices |
| `*_repeater_bridge_espnow` | Build-dependent on ESP32 | No | Uses ESP-NOW for its bridge; a FULL build also exposes the TCP 5001 LoRa-OTA seeder whenever WiFi is usable |
| RS232 bridge | No | No | Bridges through a serial interface |
| Ethernet repeater/room server | No WiFi | No | Uses wired Ethernet for its role-specific network interface |
| Sensor, terminal-chat, or KISS modem | No in current targets | No | Uses LoRa and its role-specific local interface |
| `*_sensor` | Build-dependent on ESP32 | No | A FULL ESP32 sensor can expose the TCP 5001 LoRa-OTA seeder through its browser-OTA setup AP |
| Terminal-chat or KISS modem | No in current targets | No | Uses LoRa and its role-specific local interface |
| `*_lora_ota_no_external_sensors` | No | No | Lean repeater image for receiving firmware over LoRa |
Direct on-device WiFi and MQTT are currently ESP32 features. nRF52, STM32, and
@@ -92,7 +93,7 @@ best effort rather than durable storage. Each enabled slot publishes
independently, so one failed broker does not intentionally stop the other
slots.
See [MQTT_IMPLEMENTATION.md](MQTT_IMPLEMENTATION.md) for the complete preset
See [MQTT_IMPLEMENTATION.md](../MQTT_IMPLEMENTATION.md) for the complete preset
list, custom broker configuration, topic formats, authentication, diagnostics,
and memory limits.
@@ -195,6 +196,27 @@ When `ENABLE_OTA` is included, a WiFi companion also listens on:
These ports do not replace the companion protocol on TCP 5000.
FULL ESP32 builds share the port 5001 folder seeder. It starts whenever that
role has a usable WiFi station or setup access point and stops when WiFi stops.
For example, a FULL repeater can run `start webconfig` to join its saved
network, then accept:
```bash
motatool serve --dir ./motas --tcp <repeater-ip>:5001 -v
```
The TCP connection supplies `.mota` files for the node to advertise and relay
over LoRa; it is not a raw `.bin` uploader. `start ota` continues to provide the
direct browser uploader on HTTP port 80. A FULL role without WebConfig but with
browser OTA support can use the `MeshCore-OTA` access point raised by
`start ota`; its seeder address is `192.168.4.1:5001`.
Only one external folder link can be active. A TCP client is rejected while
`ota folder on` is using USB serial, and disconnecting `motatool` automatically
removes the TCP folder. Port 5001 has no login layer, so expose it only on a
trusted LAN or temporary setup network. Firmware target and hash checks still
apply at the receiving node, along with its configured signature/trust policy.
## WiFi companion with MQTT
A `*_companion_radio_wifi_mqtt` build combines both systems:
@@ -221,6 +243,27 @@ In this case WebConfig owns WiFi only while it is needed. Stopping the portal
disconnects WiFi and turns the WiFi radio off. There is no persistent MQTT
connection keeping WiFi active.
FULL standard and FULL logging repeater/room-server builds provide these
read-only CLI checks even though they do not include MQTT:
```text
get wifi.ssid
get wifi.status
get webui
```
`get wifi.status` distinguishes an unconfigured node, an inactive WiFi radio, a
station connection attempt, the setup AP, a connection failure, and a working
LAN connection. For a LAN connection it reports the SSID, IP address, and RSSI.
An inactive status is normal when `webui` is off: run `start webconfig` to
connect temporarily. Change standalone WiFi credentials through WebConfig;
the MQTT observer `set wifi.*` CLI belongs to observer builds.
`get webui` starts with the saved boot setting, then reports the current
session. For example, `> off, http://192.168.1.130/` means automatic WebConfig
startup is saved as off, but a temporary session started by `start webconfig`
is currently active at that URL.
## Build profiles
`build.sh` produces several profiles. A profile changes the features compiled
@@ -232,8 +275,8 @@ role.
| Standard | Uses the selected target's role. Ordinary portable ESP32 repeater/room-server artifacts omit WebConfig to fit the legacy app slot. Explicit MQTT and WiFi-companion targets still use WiFi. |
| Logging | Enables USB/debug packet logging and disables the MQTT bridge. Logging output is not an MQTT uplink. |
| MQTT | Builds explicit MQTT observer or WiFi-companion-MQTT targets with USB packet logging off. |
| FULL ESP32 | Uses expanded dual-OTA partitions, retains LoRa OTA, and restores full-size ESP32 features such as WebConfig where the selected target supports them. MQTT is present only when the selected target is an MQTT target. |
| FULL ESP32 logging | Same expanded/full feature profile with debug and packet logging enabled. An MQTT target retains MQTT; a standard target remains non-MQTT. |
| FULL ESP32 | Uses expanded dual-OTA partitions, retains LoRa OTA, restores full-size ESP32 features such as WebConfig where supported, and exposes the TCP 5001 OTA seeder while WiFi is active. MQTT is present only when the selected target is an MQTT target. |
| FULL ESP32 logging | Same expanded/full feature and OTA profile with debug and packet logging enabled. An MQTT target retains MQTT; a standard target remains non-MQTT. |
| LoRa-OTA no-external-sensors | A small radio-only repeater image; no WiFi or MQTT. |
The interactive Option 1 **FULL everything** choice means every supported
@@ -268,12 +311,12 @@ WiFi only, not LoRa transmit power.
## Recognizing the wrong firmware
Commands such as `get wifi.status`, `get mqtt.status`, and
`set mqtt1.preset ...` exist only in firmware that compiled the MQTT observer
CLI. If the response is `Unsupported in this firmware`, the running image does
not contain that configuration command, or it is not the expected MQTT target.
Older portable builds report the same condition as
`unknown portable config`.
`get wifi.status` and `get wifi.ssid` are available on MQTT observers and on
FULL non-MQTT repeater/room-server builds with WebConfig. MQTT commands such as
`get mqtt.status` and `set mqtt1.preset ...` still require an MQTT observer
target. If the response is `Unsupported in this firmware`, the running image
does not contain that configuration command, or it is not the expected target.
Older portable builds report the same condition as `unknown portable config`.
Check the complete firmware filename and role. In particular:
+18
View File
@@ -113,6 +113,13 @@ On an ESP32 build with WebConfig, the manual OTA uploader and WebConfig both use
port 80 and cannot run together. Stop WebConfig before `start ota`, or stop OTA
before `start webconfig`.
FULL ESP32 builds also expose the `.mota` folder seeder on TCP port 5001
whenever WiFi is usable. This listener is independent of port 80, so a host can
run `motatool serve --dir ./motas --tcp <node-ip>:5001` while WebConfig is
active. The TCP connection auto-attaches and detaches the folder; do not run
`ota folder on` at the same time because that command selects the USB-serial
folder transport.
---
### Browser configuration portal (ESP32 repeater and room server)
@@ -125,6 +132,8 @@ before `start webconfig`.
- `set webui on`
- `set webui off`
- `get webui`
- `get wifi.ssid`
- `get wifi.status`
`set webui on` is the persistent master switch. It starts the portal now and
again after future reboots; `set webui off` closes it and disables that boot
@@ -132,6 +141,8 @@ start. `get webui` reports the saved on/off state plus whether the portal is
inactive, joining WiFi, serving a setup AP, or serving a LAN URL. The default is
`off` on repeater and room-server builds. On display-equipped repeaters, an
otherwise-unused triple click toggles the same saved setting.
Consequently, `> off, http://192.168.1.130/` means the saved boot setting is
off while a temporary WebConfig session is currently active at that URL.
`start webconfig` is a temporary start that does not change the saved switch.
It serves the shared WiFi, radio, flood, loop, and status page and reports its
@@ -146,6 +157,13 @@ mode for the current boot. (`stop webconfig` does not change a saved `webui on`.
LAN mode otherwise remains active until reboot; setup mode stops after 10 minutes
with no connected client.
On FULL standard and FULL logging ESP32 repeater/room-server builds,
`get wifi.ssid` reports the saved standalone WebConfig network and
`get wifi.status` reports whether WiFi is unconfigured, off, connecting,
running the setup AP, failed, or connected. A connected result includes the
SSID, LAN IP, and RSSI. WiFi being off is normal while WebConfig is inactive;
run `start webconfig` when a temporary connection is wanted.
The browser portal is not compiled into the two 4 MB
`LilyGo_TLora_V2_1_1_6_*_observer_mqtt` targets because it does not fit while
retaining the two app slots required for LoRa OTA. Their normal CLI settings
+16 -10
View File
@@ -40,8 +40,9 @@ Both paths require:
- An OTA-enabled build whose artifact filename contains `-ota-` on the destination, plus an OTA-enabled
repeater on every intermediate hop. The `-ota-` stamp confirms that LoRa OTA is compiled into the build.
Logging, portable MQTT, and untagged builds cannot perform LoRa OTA; use WiFi or USB to replace them first.
- An ESP32 MeshCore companion connected to the computer by USB serial or WiFi as described below.
Portable logging, portable MQTT, and untagged builds cannot perform LoRa OTA; FULL logging OTA builds can.
- An OTA-enabled MeshCore source connected to the computer by USB serial, or an ESP32 WiFi companion/FULL
ESP32 source connected over WiFi as described below.
- Overlapping `tempradio` windows on the source, destination, and every repeater needed between them.
LoRa OTA packets are generated, received, and relayed only while `tempradio` is active. If any required
@@ -61,28 +62,33 @@ logging enabled. The logging profile is intended for diagnostics and can produce
### Choose the source radio
Use an ESP32 **MeshCore companion** as the source node. It receives the update folder from the computer,
then advertises it over LoRa. Current ESP32 USB and WiFi companion OTA artifacts include `-ota-` in their
filenames and compile in the required OTA transport. A small set of high-capacity classic ESP32 companions
Use an OTA-enabled MeshCore node as the source. It receives the update folder from the computer, then
advertises it over LoRa. ESP32 USB/WiFi companions and FULL ESP32 roles include the required transport.
A small set of high-capacity classic ESP32 companions
keep their normal image and provide a separate `-full-ota-` image with 100 contacts, 8 group channels, and
a 16-frame offline queue. Install that variant's merged image over USB once before using it. Connect the
companion either by USB serial or by WiFi. For USB serial, confirm that its USB CLI accepts:
source by USB serial or, when supported, by WiFi. For USB serial, confirm that its USB CLI accepts:
```text
ota folder on
```
If an older build reports that `OTA_FOLDER_SERIAL` is not compiled in, install a current `-ota-` or
`-full-ota-` companion build first. Do **not** use a KISS modem: KISS firmware is a TNC/KISS frame interface
`-full-ota-` build first. Do **not** use a KISS modem: KISS firmware is a TNC/KISS frame interface
and does not provide the MeshCore CLI or the OTA-folder transport that `motatool serve` requires.
For a companion connected by WiFi, use its dedicated OTA seeder connection instead:
For an ESP32 WiFi companion or FULL ESP32 source with active WiFi, use its dedicated OTA seeder:
```bash
motatool serve --dir ./motas --tcp <companion-host>:5001 -v
motatool serve --dir ./motas --tcp <source-host>:5001 -v
```
Port `5001` is the OTA seeder port; it is separate from the companion application port (`5000`).
Port `5001` is separate from the companion application port (`5000`) and the
infrastructure WebConfig/browser-OTA port (`80`). On a FULL repeater or room
server, `start webconfig` can bring up the saved WiFi connection. Other FULL
roles with browser OTA support can raise `MeshCore-OTA` with `start ota` and
use `192.168.4.1:5001`. The TCP seeder auto-attaches; do not also run
`ota folder on` for USB serial.
## Install `motatool`
+13 -10
View File
@@ -574,7 +574,8 @@ blocks) and streams payload blocks from the source on demand; proofs are generat
### 10.2 The `mota-seeder` transport (`MotaSeederProto.h`)
A `MotaSource` is fed by a host that serves a folder over the device's **USB serial** (the same console the
CLI uses - no extra hardware) or, on an ESP32 WiFi companion, over **WiFi (TCP)**. The host is the
CLI uses - no extra hardware) or, on an ESP32 WiFi companion or FULL ESP32 role, over **WiFi (TCP)**. The
host is the
standalone Rust tool [`motatool`](https://github.com/vk496/motatool) (`motatool serve --serial <port>` /
`--tcp <host[:port]>`, which also builds + verifies + inspects `.mota`). The device only emits request frames *while
actively serving a fetch*, and reads the reply synchronously, so over the shared USB console binary frames
@@ -592,20 +593,22 @@ MotaDesc wire (38 B): mid[4] target_id(4) fw_version(4) codec(1) flags(1)
status: 0 = OK, non-zero = error (out of range / past EOF).
```
**What to plug into `--serial`.** Use the USB serial console of an ESP32 MeshCore **companion** built with
`OTA_FOLDER_SERIAL`. The companion is the required source node and must have a working LoRa radio plus an
**What to plug into `--serial`.** Use the USB serial console of an OTA-enabled MeshCore node built with
`OTA_FOLDER_SERIAL`. The node must have a working LoRa radio plus an
`ota folder on` command; that command confirms it can host and advertise the folder. A **KISS modem will not
work**: KISS firmware exposes a TNC/KISS frame interface, not the MeshCore CLI and `mota-seeder`
request/response transport. A companion connected over WiFi is the alternative source connection: use its
dedicated seeder port with `motatool serve --tcp <host>:5001`.
request/response transport. An ESP32 WiFi companion or FULL ESP32 role with active WiFi is the alternative
source connection: use its dedicated seeder port with `motatool serve --tcp <host>:5001`.
Device CLI: `ota folder on` (attach + announce), `ota folder` (list), `ota folder off`. Build flag
`OTA_FOLDER_SERIAL` (default stream = console `Serial`; override `OTA_FOLDER_SERIAL_STREAM` + define
`OTA_FOLDER_SERIAL_BEGIN` for a dedicated UART). On an ESP32 WiFi companion the node also runs a second
`WiFiServer` on a **dedicated seeder port** (`OTA_SEEDER_TCP_PORT`, default `5001`), separate from the
companion app port (`TCP_PORT`, default `5000`) - so `motatool serve --tcp` can feed updates while a phone
app stays connected. The node auto-attaches the source when a seeder client connects and detaches when it
closes (no `ota folder on` needed over TCP). Verified on hardware: a RAK4631 relays a host folder to a
`OTA_FOLDER_SERIAL_BEGIN` for a dedicated UART). ESP32 WiFi companions and FULL ESP32 roles run a
`WiFiServer` on the **dedicated seeder port** (`OTA_SEEDER_TCP_PORT`, default `5001`) while WiFi is usable.
On a companion it is separate from the app port (`TCP_PORT`, default `5000`); on infrastructure roles it is
separate from WebConfig and browser OTA on port 80. The node auto-attaches the source when a seeder client
connects and detaches when it closes (no `ota folder on` needed over TCP). An already-active serial folder
causes a TCP client to be rejected instead of silently replacing it. Verified on hardware: a RAK4631
relays a host folder to a
Heltec V3 over one USB cable, and a host feeds a Heltec V3 over WiFi (`:5001`) while the companion serves a
phone on `:5000` - every block merkle-checked.
+8 -4
View File
@@ -235,14 +235,13 @@ remote area.
1. Put the firmware files (`.mota` files - see below) in a folder on the computer.
2. Install the helper tool once - the standalone `motatool` CLI (<https://github.com/vk496/motatool>) -
then point it at your node and the folder - over the node's **USB serial**, or over **WiFi** if it's
an ESP32 companion on your network:
then point it at your node and the folder - over the node's **USB serial**, or over **WiFi** if it is
an ESP32 WiFi companion or FULL ESP32 node:
```
git clone https://github.com/vk496/motatool && cargo install --path ./motatool
# over USB serial:
motatool serve --dir ./my_firmware/ --serial /dev/ttyACM0 -v
# ...or over WiFi (ESP32 companion): the seeder is on a DEDICATED port (5001), separate from the
# phone-app port (5000), so a phone can stay connected while you serve:
# ...or over WiFi: the seeder is on dedicated TCP port 5001:
motatool serve --dir ./my_firmware/ --tcp 192.168.1.50:5001 -v
```
It answers the node's requests; your node then advertises those updates to neighbours, who can
@@ -251,6 +250,11 @@ remote area.
To stop, just stop the daemon - over WiFi the node auto-detaches when the connection closes; over USB you
can also run `ota folder off` on the node. `ota folder` on its own lists what your node is offering.
On a FULL repeater or room server, run `start webconfig` first if WiFi is not
already active. Other FULL roles with browser OTA support can use the
`MeshCore-OTA` access point from `start ota` and connect to
`192.168.4.1:5001`. Every LoRa participant still needs an overlapping
`tempradio` window.
### Everyone helps share
-49
View File
@@ -192,52 +192,6 @@ void halt() {
}
#endif
/* WIFI OTA SEEDER - relay a host folder of .mota over WiFi (motatool `serve --tcp`), on a DEDICATED port
separate from the companion (TCP_PORT), so a phone app stays connected while motatool feeds updates. */
#if defined(ESP32) && defined(WIFI_SSID) && defined(ENABLE_OTA)
#include <helpers/ota/OtaContext.h>
#include <helpers/ota/MotaSourceSerial.h>
#include <helpers/ota/FolderMotaStore.h> // `ota pull <#> folder` destination over this same connection
#ifndef OTA_SEEDER_TCP_PORT
#define OTA_SEEDER_TCP_PORT 5001
#endif
static WiFiServer ota_seeder_server(OTA_SEEDER_TCP_PORT);
static WiFiClient ota_seeder_client; // the live seeder connection (reused)
static mesh::ota::SerialMotaSource ota_seeder_source(ota_seeder_client, 3000); // SERVE: read folder -> relay
static mesh::ota::FolderMotaStore ota_folder_store(ota_seeder_client, 3000); // PULL: capture .mota -> folder
static bool ota_seeder_attached = false;
// Accept one motatool connection at a time. While connected, the same link both SERVES the host folder
// over LoRa (register it as a source) and is offered as a `folder` PULL destination (`ota pull <#> folder`
// captures a fetched .mota back to that folder). Drop both the moment the connection closes.
static void ota_seeder_loop() {
if (ota_seeder_client && ota_seeder_client.connected()) return; // still serving the current client
if (ota_seeder_attached) { // previous client just disconnected
mesh::ota::ota_ctx().detach_folder();
mesh::ota::ota_ctx().clear_folder_dest(); // the `folder` pull destination is gone too
mesh::ota::ota_ctx().manager.announce(); // served set shrank back to our own fw -> re-advertise
ota_seeder_attached = false;
WIFI_DEBUG_PRINTLN("OTA seeder: client disconnected, relay stopped");
}
WiFiClient c = ota_seeder_server.available();
if (c) {
ota_seeder_client = c; // rebind the persistent Stream to it
if (mesh::ota::ota_ctx().manager.add_source(&ota_seeder_source)) {
ota_seeder_attached = true;
char di[24]; snprintf(di, sizeof di, "tcp %s", ota_seeder_client.remoteIP().toString().c_str());
mesh::ota::ota_ctx().set_folder_dest(&ota_folder_store, di); // offer `ota pull <#> folder`
// if a folder pull PAUSED when the link dropped, the host still holds the partial: rescan + resume
if (mesh::ota::ota_ctx().manager.fetchState() == mesh::ota::OtaManager::PAUSED)
mesh::ota::ota_ctx().manager.resumeStaged(nullptr);
mesh::ota::ota_ctx().manager.announce(); // new served set -> advertise the folder's fw to peers
WIFI_DEBUG_PRINTLN("OTA seeder: client connected (%s) - relay + folder pull-dest ready", di);
} else {
ota_seeder_client.stop(); // no free source slot
}
}
}
#endif
/* WIFI OTA CONSOLE - a tiny text CLI for OTA over WiFi. A WiFi companion has no serial text console, so
without this its OTA is only reachable through the phone app. Connect with e.g. `nc <ip> 5002` and type
`ota status` / `ota ls` / `ota announce` / ... - one client at a time, on a DEDICATED port separate from
@@ -449,8 +403,6 @@ void setup() {
WiFi.setSleep(false);
serial_interface.begin(TCP_PORT);
#ifdef ENABLE_OTA
ota_seeder_server.begin(); // dedicated OTA seeder port for `motatool serve --tcp` (relay over LoRa)
WIFI_DEBUG_PRINTLN("OTA seeder listening on :%d (motatool serve --tcp)", OTA_SEEDER_TCP_PORT);
ota_console_server.begin(); // dedicated OTA text-console port (`nc <ip> 5002` -> `ota ...`)
WIFI_DEBUG_PRINTLN("OTA console listening on :%d (nc <ip> %d, type `ota ...`)", OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT);
#endif
@@ -567,7 +519,6 @@ void loop() {
the_mesh.serviceWebConfig();
#endif
#ifdef ENABLE_OTA
ota_seeder_loop(); // accept/drop a motatool `serve --tcp` connection on the dedicated seeder port
ota_console_loop(); // service the OTA text console (port 5002)
#endif
if (WiFi.status() == WL_CONNECTED) {
+8
View File
@@ -7658,6 +7658,14 @@ bool MyMesh::getWebUIStatus(char* reply) const {
return true;
}
bool MyMesh::getWiFiSSID(char* reply) const {
return WebConfigServer::formatWiFiSSID(reply, 160);
}
bool MyMesh::getWiFiStatus(char* reply) const {
return WebConfigServer::formatWiFiStatus(reply, 160);
}
void MyMesh::onConfigBatchEnd() {
_wc_batch_active = false;
#ifdef WITH_MQTT_BRIDGE
+2
View File
@@ -958,6 +958,8 @@ public:
bool stopWebConfig(char* reply) override;
bool setWebUIEnabled(bool enabled, char* reply) override;
bool getWebUIStatus(char* reply) const override;
bool getWiFiSSID(char* reply) const override;
bool getWiFiStatus(char* reply) const override;
bool isWebConfigActive() const override {
return _webconfig && (_webconfig->isRunning() || _webconfig->isStopping());
}
+8
View File
@@ -1375,6 +1375,14 @@ bool MyMesh::getWebUIStatus(char* reply) const {
return true;
}
bool MyMesh::getWiFiSSID(char* reply) const {
return WebConfigServer::formatWiFiSSID(reply, 160);
}
bool MyMesh::getWiFiStatus(char* reply) const {
return WebConfigServer::formatWiFiStatus(reply, 160);
}
bool MyMesh::stopWebConfig(char* reply) {
if (!_webconfig || !_webconfig->isRunning()) {
strcpy(reply, "Err: webconfig not running");
+2
View File
@@ -481,6 +481,8 @@ public:
bool stopWebConfig(char* reply) override;
bool setWebUIEnabled(bool enabled, char* reply) override;
bool getWebUIStatus(char* reply) const override;
bool getWiFiSSID(char* reply) const override;
bool getWiFiStatus(char* reply) const override;
bool isWebConfigActive() const override {
return _webconfig && (_webconfig->isRunning() || _webconfig->isStopping());
}
+1
View File
@@ -89,6 +89,7 @@ lib_deps = ${arduino_base.lib_deps}
build_src_filter = ${arduino_base.build_src_filter}
+<helpers/esp32/WebConfigServer.cpp>
+<helpers/esp32/PortableErrorStrings.cpp>
+<helpers/esp32/WiFiOtaSeeder.cpp>
+<helpers/ota/*.cpp>
+<helpers/ota/detools/detools.c>
+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
@@ -0,0 +1,30 @@
#include <gtest/gtest.h>
#include <helpers/WiFiOtaSeederPolicy.h>
namespace Policy = WiFiOtaSeederPolicy;
TEST(WiFiOtaSeederPolicy, ListenerTracksNetworkAvailability) {
EXPECT_EQ(Policy::ListenerAction::Keep, Policy::listenerAction(false, false));
EXPECT_EQ(Policy::ListenerAction::Start, Policy::listenerAction(true, false));
EXPECT_EQ(Policy::ListenerAction::Keep, Policy::listenerAction(true, true));
EXPECT_EQ(Policy::ListenerAction::Stop, Policy::listenerAction(false, true));
}
TEST(WiFiOtaSeederPolicy, ExistingSerialFolderCannotBeReplacedByTcp) {
EXPECT_TRUE(Policy::canAttachTcpFolder(false, false));
EXPECT_FALSE(Policy::canAttachTcpFolder(true, false));
EXPECT_TRUE(Policy::canAttachTcpFolder(true, true));
}
TEST(WiFiOtaSeederPolicy, DetectsExternalDetachOfTcpFolder) {
EXPECT_FALSE(Policy::tcpFolderWasDetached(false, false));
EXPECT_FALSE(Policy::tcpFolderWasDetached(false, true));
EXPECT_FALSE(Policy::tcpFolderWasDetached(true, true));
EXPECT_TRUE(Policy::tcpFolderWasDetached(true, false));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}