Add WebConfig CLI and standalone WiFi controls

This commit is contained in:
mikecarper committed 2026-07-31 01:19:17 -07:00
1 parent 7bbde76380
commit 3f5e497ca6
24 files changed
+2698 -1224

No files matched your search

+70 -10
View File
@@ -133,6 +133,7 @@ Useful checks are:
get wifi.ssid
get wifi.status
get wifi.powersave
get wifi.cli
get bridge.enabled
get mqtt.rx
get mqtt.tx
@@ -217,6 +218,17 @@ 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.
When the shared seeder is running, `get wifi.status` appends its live state:
```text
OTA TCP 5001: listening
OTA TCP 5001: client connected
```
The first state means WiFi is usable and the node is waiting for
`motatool serve --tcp`. The second means a `motatool` folder is currently
attached and available for LoRa OTA service.
## WiFi companion with MQTT
A `*_companion_radio_wifi_mqtt` build combines both systems:
@@ -244,26 +256,73 @@ 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:
CLI controls and status checks even though they do not include MQTT:
```text
get wifi.ssid
get wifi.status
get wifi.powersave
get wifi.cli
get webui
set wifi.ssid SlowFi
set wifi.pwd your-password
set wifi.powersave none
set wifi.cli on
```
`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.
If the shared OTA seeder is active, the same reply also reports whether TCP
port 5001 is listening or has a `motatool` client attached.
`get wifi.powersave` reports the saved standalone WebConfig setting as `none`,
`min`, or `max`.
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.
connect temporarily. Standalone credentials can be changed through WebConfig
or with the listed CLI commands. Changing the SSID or password stops an active
WebConfig session; start it again to connect with the new values. Use
`set wifi.pwd` with no value for an open network. The password is write-only
and is never returned by `get`.
`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.
### WebConfig CLI terminal
The terminal defaults to on. Enable or disable it from an existing admin CLI:
```text
set wifi.cli on
set wifi.cli off
get wifi.cli
```
The saved `on` setting becomes active only when the WiFi station client is
connected and WebConfig is running in LAN mode. It is never exposed on the open
setup access point. When active, the WebConfig page has a **CLI** tab for sending
commands directly to the repeater or room server. It uses the local
administrator command parser and displays one reply at a time. The terminal is
protected by the WebConfig admin login and uses remote-administrator
permissions, so commands explicitly restricted to a physical serial connection
remain unavailable.
Use **Single command** for the normal prompt, or select **Command block** to
paste up to 100 commands with one command per line. Blank lines are ignored.
The browser validates all lines first, then sends one command at a time and
waits for its reply before sending the next. The block queue is kept in the
browser only, so closing the page or losing its WiFi connection stops the
commands that have not yet been sent. Ctrl+Enter or Command+Enter starts a
block.
The up/down arrow keys recall commands entered during the current browser
session in single-command mode. Commands available in the terminal still
depend on the firmware role and build profile. Commands such as
`stop webconfig`, `set wifi.cli off`, WiFi credential changes, and reboot
operations stop the remaining block and can disconnect the page before it
receives their final reply.
## Build profiles
`build.sh` produces several profiles. A profile changes the features compiled
@@ -292,7 +351,7 @@ updates using the same layout normally preserve them.
## WiFi power behavior
MQTT observers support:
MQTT observers and FULL standalone ESP32 repeater/room-server builds support:
```text
set wifi.powersave none
@@ -311,12 +370,13 @@ WiFi only, not LoRa transmit power.
## Recognizing the wrong firmware
`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`.
`get wifi.status`, `get wifi.ssid`, `get wifi.powersave`, and `get wifi.cli`
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. Unknown settings return `Error: unknown setting:
<name>`. Older portable builds can instead report `Unsupported in this
firmware` when a command was intentionally cut for space.
Check the complete firmware filename and role. In particular:
+115
View File
@@ -0,0 +1,115 @@
# CLI Availability by Firmware Build
MeshCore command availability is determined in three layers:
1. **Role** - repeater, room server, sensor, companion, bridge, or KISS modem.
2. **Build profile** - standard, portable, logging, OTA, or FULL.
3. **Compiled hardware features** - WiFi, MQTT, GPS, external sensors, PSRAM,
Ethernet, and similar optional support.
The complete command descriptions are in [CLI Commands](cli_commands.md). This
page describes the commands intentionally omitted or limited by build profile.
## Role comes first
| Role | Text administration CLI |
|---|---|
| Repeater | Full repeater administration surface, subject to the profile differences below |
| Room server | Room-server administration surface, subject to the profile differences below |
| Sensor | Sensor command surface; it does not acquire the repeater administration tree |
| Serial, USB, BLE, or WiFi companion | Uses the companion protocol; any serial diagnostics are target-specific |
| KISS modem | Uses the KISS/TNC frame interface, not the repeater text CLI |
| Bridge | Uses its base role plus commands for the bridge transport compiled into that target |
A command belonging to a different role is not considered a profile cut. For
example, adding FULL features to a sensor does not turn it into a repeater
administrator.
## Profile matrix
| Build/profile | Command availability |
|---|---|
| Standard non-MQTT repeater or room server | Keeps the normal role CLI. Size-constrained ESP32 artifacts can omit WebConfig and browser WiFi OTA, so their WebConfig/WiFi commands are unavailable. |
| Standard logging | Logging does not remove commands by itself. It has the same CLI as the selected role/profile and adds the compiled logging behavior. |
| LoRa-OTA (`-ota-`) | LoRa OTA adds the `ota ...` commands; it does not otherwise reduce the role CLI. A portable OTA artifact can still have the portable restrictions described below. |
| Portable MQTT observer | Keeps MQTT/WiFi commissioning, bridge control, radio essentials, update commands, basic identity/status commands, `neighbors`, and `discover.neighbors`. The large repeater administration tree is omitted to fit the legacy ESP32 application slot. |
| Portable ESP-NOW bridge | Keeps the repeater's role-specific handlers and a reduced common configuration surface containing radio and bridge essentials. |
| FULL ESP32 | Removes size-based CLI cuts and restores the complete command surface supported by the role and hardware. |
| FULL ESP32 logging | Same command coverage as FULL ESP32, with debug and packet logging enabled. |
| `no_external_sensors` | Removes optional external-sensor drivers and their settings; it does not remove core repeater discovery or routing commands. |
`logging`, `OTA`, and `FULL` describe independent build features. Do not infer
that a command is missing merely because `logging` appears in the filename.
## Portable MQTT observer retained surface
The portable MQTT observer keeps these command groups:
- lifecycle and identity: `reboot`, `poweroff`, `shutdown`, `ver`, `board`,
`password`, and `erase` on the local console;
- radio operation: `advert`, `advert.zerohop`, `clock`, `clock sync`, `time`,
`memory`, `neighbors`, and `discover.neighbors`;
- browser/update control when compiled: `start ota`, `stop ota`, `ota check`,
and `ota update`;
- radio essentials through `get`/`set`: radio parameters, TX power, CAD,
interference threshold, AGC reset interval, RX gain, repeat state, and
applicable FEM controls;
- MQTT, WiFi, NTP, bridge, and alert commands implemented by the observer
feature set;
- region and onboard-GPS commands that fit and are compiled into the selected
target.
Everything in the repeater-only administration tree that is not listed above is
intentionally cut from this portable profile. The main omissions include ACL
editing, flood filter/moderation/scope administration, advanced mesh-clock
controls, recent-repeater/path administration, battery-alert/RX-watchdog
controls, stored-log management, and external-sensor administration. Use the
matching FULL ESP32 build when those commands are required.
Some observer commands have their own hardware limit:
- `discover.scopes` and MQTT neighbor-table publishing require PSRAM.
- `discover.neighbors` does **not** require MQTT or PSRAM.
- full NTP connectivity diagnostics are omitted from the portable profile.
## Discovery invariant
`discover.neighbors` sends the zero-hop node-discovery request used to refresh
the repeater neighbor table. It is available in every repeater build profile,
including portable MQTT, standard, logging, OTA, FULL, and FULL logging builds.
The exact command is:
```text
discover.neighbors
```
It accepts no options and returns:
```text
OK - Discover sent
```
Some MQTT room-server targets also expose the command as part of their compiled
neighbor-table feature. It is not a cross-role guarantee: companion, KISS,
sensor, and ordinary room-server firmware use different interfaces or do not
maintain the repeater administrator neighbor table.
## Feature-dependent commands
Even in a FULL build, a command can be unavailable when its underlying feature
does not exist on that target:
- WebConfig and the `wifi.ssid`, `wifi.status`, `wifi.powersave`, and `wifi.cli` command
family require an ESP32 WebConfig build. FULL standalone repeater and
room-server builds support the corresponding WiFi setters and status
commands.
- MQTT commands require an MQTT observer target.
- `discover.scopes` requires MQTT neighbor support and PSRAM.
- GPS and external-sensor commands require their drivers and pins.
- Ethernet and bridge commands require the corresponding transport.
- LoRa OTA commands require an artifact with OTA enabled.
- `uf2reset` applies only to nRF52.
When diagnosing an unavailable command, check the role first, then the filename
profile, and finally the target's compiled hardware features.
+55 -6
View File
@@ -2,6 +2,10 @@
This document provides an overview of CLI commands that can be sent to MeshCore Repeaters, Room Servers and Sensors.
See [CLI Availability by Firmware Build](cli_build_matrix.md) for the role and
profile matrix, including the commands intentionally omitted from portable
firmware.
The first word of a command is case-insensitive, so `set`, `Set`, and `SET`
are equivalent, as are `get`, `Get`, and the other command verbs. The case of
arguments such as node names, passwords, and keys is left unchanged.
@@ -134,6 +138,12 @@ folder transport.
- `get webui`
- `get wifi.ssid`
- `get wifi.status`
- `get wifi.powersave`
- `get wifi.cli`
- `set wifi.ssid <network name>`
- `set wifi.pwd [password]`
- `set wifi.powersave <none|min|max>`
- `set wifi.cli <on|off>`
`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
@@ -152,17 +162,50 @@ WiFi SSID, the command starts the open `MeshCore-Setup-XXXX` captive AP at
<http://192.168.4.1/> instead.
`start webconfig ap` forces captive-AP mode. It will not interrupt an active
MQTT bridge, so run `set bridge off` first. Use `stop webconfig` to close either
mode for the current boot. (`stop webconfig` does not change a saved `webui on`.)
MQTT bridge, so run `set bridge.enabled off` first. Use `stop webconfig` to
close either 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.
The saved `wifi.cli` setting defaults to `on`. Use `set wifi.cli off` to disable
the **CLI** tab.
`get wifi.cli` reports `off`, `on, waiting for WiFi client`, or `on, active`.
The saved setting becomes active only in station/LAN mode while the WiFi client
is connected. It is deliberately unavailable on the open setup access point.
The tab sends one command at a time through the local administrator command
parser and displays the reply in the browser. It uses remote-administrator
command permissions, so commands explicitly restricted to a physical serial
connection remain unavailable.
Select **Command block** to paste up to 100 commands with one command per line.
Blank lines are ignored, and every nonblank line must fit the normal 159-byte
CLI command limit. The browser sends the lines sequentially and waits for each
reply before sending the next line. The queue exists only in that browser page;
closing it stops any commands that have not yet been sent. A lost connection
also stops the remaining block.
Up/down arrow keys recall commands from the current browser session in
single-command mode. A command that stops WebConfig, changes its WiFi
connection, disables `wifi.cli`, or reboots the node stops the remaining block
and can close the page before its reply is collected.
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.
SSID, LAN IP, and RSSI. When the shared OTA seeder is running, the reply appends
`OTA TCP 5001: listening` or `OTA TCP 5001: client connected`. WiFi being off
is normal while WebConfig is inactive; run `start webconfig` when a temporary
connection is wanted. `get wifi.powersave` reports the saved standalone setting
as `none`, `min`, or `max`; the default is `none`.
The WiFi `set` commands work on MQTT observers and on FULL standalone
ESP32 repeater/room-server builds. On a standalone build, changing the SSID or
password stops an active WebConfig session; run `start webconfig` again to use
the new credentials. `set wifi.pwd` with no value selects an open network.
Power-save changes are applied immediately when WiFi is running and otherwise
take effect on the next connection. `get wifi.pwd` is intentionally unavailable
so the standalone password is never returned by the CLI.
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
@@ -207,6 +250,10 @@ remain available.
**Usage:**
- `discover.neighbors`
This command is available in every repeater build profile, including portable
MQTT, standard, logging, OTA, FULL, and FULL logging artifacts. It does not
require MQTT or PSRAM.
---
### Discover neighbor scopes (MQTT observer, PSRAM only)
@@ -1017,7 +1064,7 @@ hardware busy-result count.
---
#### View or change the radio watchdog interval
#### View or change the radio watchdog interval (MQTT observer only)
**Usage:**
- `get radio.watchdog`
- `set radio.watchdog <minutes>`
@@ -1027,7 +1074,9 @@ hardware busy-result count.
**Default:** `5`
**Note:** On quiet meshes, increasing this can reduce false recoveries when no traffic is expected.
**Note:** This watchdog belongs to the MQTT observer runtime and is not
available on a standalone FULL repeater. On quiet meshes, increasing it can
reduce false recoveries when no traffic is expected.
---
+1
View File
@@ -6,6 +6,7 @@ Below are a few quick start guides.
- [Frequently Asked Questions](./faq.md)
- [CLI Commands](./cli_commands.md)
- [CLI Availability by Firmware Build](./cli_build_matrix.md)
- [Easy LoRa OTA: ESP32 full images and nRF52 deltas](./ota_easy.md)
- [GPS Tracking](./gps_tracking.md)
- [Companion Protocol](./companion_protocol.md)
+65 -4
View File
@@ -7666,6 +7666,51 @@ bool MyMesh::getWiFiStatus(char* reply) const {
return WebConfigServer::formatWiFiStatus(reply, 160);
}
bool MyMesh::getWiFiPowerSave(char* reply) const {
return WebConfigServer::formatWiFiPowerSave(reply, 160);
}
bool MyMesh::getWiFiCLI(char* reply) const {
return WebConfigServer::formatWiFiCliStatus(reply, 160);
}
bool MyMesh::setWiFiSSID(const char* value, char* reply) {
if (WebConfigServer::setStandaloneWiFiSSID(value, reply, 160)) {
const bool was_running = _webconfig && _webconfig->isRunning();
if (was_running) _webconfig->requestStop();
if (_webconfig) _webconfig->reloadStandaloneWiFi();
if (was_running) {
strcpy(reply, "OK - WiFi SSID saved; WebConfig stopping, start again to apply");
}
}
return true;
}
bool MyMesh::setWiFiPassword(const char* value, char* reply) {
if (WebConfigServer::setStandaloneWiFiPassword(value, reply, 160)) {
const bool was_running = _webconfig && _webconfig->isRunning();
if (was_running) _webconfig->requestStop();
if (_webconfig) _webconfig->reloadStandaloneWiFi();
if (was_running) {
strcpy(reply, "OK - WiFi password saved; WebConfig stopping, start again to apply");
}
}
return true;
}
bool MyMesh::setWiFiPowerSave(const char* value, char* reply) {
if (WebConfigServer::setStandaloneWiFiPowerSave(value, reply, 160)
&& _webconfig) {
_webconfig->reloadStandaloneWiFi();
}
return true;
}
bool MyMesh::setWiFiCLI(const char* value, char* reply) {
WebConfigServer::setWiFiCliEnabled(value, reply, 160);
return true;
}
void MyMesh::onConfigBatchEnd() {
_wc_batch_active = false;
#ifdef WITH_MQTT_BRIDGE
@@ -8001,8 +8046,24 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
}
mesh::cli::normalizeCommandVerb(command);
const mesh::cli::NoArgCommandMatch discover_neighbors_match =
mesh::cli::matchNoArgCommand(command, "discover.neighbors");
#if defined(PORTABLE_MQTT_OBSERVER)
// Neighbor refresh is a core repeater operation, not an MQTT feature. Keep
// it ahead of the portable observer's reduced CommonCLI handoff so every
// repeater build exposes the same command.
if (discover_neighbors_match != mesh::cli::NoArgCommandMatch::NoMatch) {
if (discover_neighbors_match ==
mesh::cli::NoArgCommandMatch::HasArguments) {
strcpy(reply, "Err - discover.neighbors has no options");
} else {
sendNodeDiscoverReq();
strcpy(reply, "OK - Discover sent");
}
return;
}
// The portable observer exposes its MQTT/WiFi/update controls through
// CommonCLI. Omit the repeater's large remote-administration command tree;
// its mesh behavior remains fixed by the selected build profile.
@@ -8392,10 +8453,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
savePrefs();
strcpy(reply, "OK");
}
} else if (memcmp(command, "discover.neighbors", 18) == 0) {
const char* sub = command + 18;
while (*sub == ' ') sub++;
if (*sub != 0) {
} else if (discover_neighbors_match !=
mesh::cli::NoArgCommandMatch::NoMatch) {
if (discover_neighbors_match ==
mesh::cli::NoArgCommandMatch::HasArguments) {
strcpy(reply, "Err - discover.neighbors has no options");
} else {
sendNodeDiscoverReq();
+10
View File
@@ -960,11 +960,21 @@ public:
bool getWebUIStatus(char* reply) const override;
bool getWiFiSSID(char* reply) const override;
bool getWiFiStatus(char* reply) const override;
bool getWiFiPowerSave(char* reply) const override;
bool getWiFiCLI(char* reply) const override;
bool setWiFiSSID(const char* value, char* reply) override;
bool setWiFiPassword(const char* value, char* reply) override;
bool setWiFiPowerSave(const char* value, char* reply) override;
bool setWiFiCLI(const char* value, char* reply) override;
bool isWebConfigActive() const override {
return _webconfig && (_webconfig->isRunning() || _webconfig->isStopping());
}
void getNodeSnapshot(WebConfigServer::NodeSnapshot& snapshot) override;
void execCommand(char* cmd, char* reply) override { handleCommand(0, cmd, reply); }
bool supportsCliTerminal() const override { return true; }
void execAdminCommand(char* cmd, char* reply) override {
handleCommand(1, cmd, reply);
}
void rebootNow() override { _cli.getBoard()->reboot(); }
void onConfigBatchStart() override {
_wc_batch_active = true;
+45
View File
@@ -1383,6 +1383,51 @@ bool MyMesh::getWiFiStatus(char* reply) const {
return WebConfigServer::formatWiFiStatus(reply, 160);
}
bool MyMesh::getWiFiPowerSave(char* reply) const {
return WebConfigServer::formatWiFiPowerSave(reply, 160);
}
bool MyMesh::getWiFiCLI(char* reply) const {
return WebConfigServer::formatWiFiCliStatus(reply, 160);
}
bool MyMesh::setWiFiSSID(const char* value, char* reply) {
if (WebConfigServer::setStandaloneWiFiSSID(value, reply, 160)) {
const bool was_running = _webconfig && _webconfig->isRunning();
if (was_running) _webconfig->requestStop();
if (_webconfig) _webconfig->reloadStandaloneWiFi();
if (was_running) {
strcpy(reply, "OK - WiFi SSID saved; WebConfig stopping, start again to apply");
}
}
return true;
}
bool MyMesh::setWiFiPassword(const char* value, char* reply) {
if (WebConfigServer::setStandaloneWiFiPassword(value, reply, 160)) {
const bool was_running = _webconfig && _webconfig->isRunning();
if (was_running) _webconfig->requestStop();
if (_webconfig) _webconfig->reloadStandaloneWiFi();
if (was_running) {
strcpy(reply, "OK - WiFi password saved; WebConfig stopping, start again to apply");
}
}
return true;
}
bool MyMesh::setWiFiPowerSave(const char* value, char* reply) {
if (WebConfigServer::setStandaloneWiFiPowerSave(value, reply, 160)
&& _webconfig) {
_webconfig->reloadStandaloneWiFi();
}
return true;
}
bool MyMesh::setWiFiCLI(const char* value, char* reply) {
WebConfigServer::setWiFiCliEnabled(value, reply, 160);
return true;
}
bool MyMesh::stopWebConfig(char* reply) {
if (!_webconfig || !_webconfig->isRunning()) {
strcpy(reply, "Err: webconfig not running");
+10
View File
@@ -483,11 +483,21 @@ public:
bool getWebUIStatus(char* reply) const override;
bool getWiFiSSID(char* reply) const override;
bool getWiFiStatus(char* reply) const override;
bool getWiFiPowerSave(char* reply) const override;
bool getWiFiCLI(char* reply) const override;
bool setWiFiSSID(const char* value, char* reply) override;
bool setWiFiPassword(const char* value, char* reply) override;
bool setWiFiPowerSave(const char* value, char* reply) override;
bool setWiFiCLI(const char* value, char* reply) override;
bool isWebConfigActive() const override {
return _webconfig && (_webconfig->isRunning() || _webconfig->isStopping());
}
void getNodeSnapshot(WebConfigServer::NodeSnapshot& snapshot) override;
void execCommand(char* cmd, char* reply) override { handleCommand(0, cmd, reply); }
bool supportsCliTerminal() const override { return true; }
void execAdminCommand(char* cmd, char* reply) override {
handleCommand(1, cmd, reply);
}
void rebootNow() override { _cli.getBoard()->reboot(); }
void onConfigBatchStart() override {
_wc_batch_active = true;
+108
View File
@@ -1,10 +1,118 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
namespace mesh {
namespace cli {
enum class StandaloneWiFiKey : uint8_t {
None = 0,
SSID,
Password,
PowerSave,
Status,
CLI,
};
enum class NoArgCommandMatch : uint8_t {
NoMatch = 0,
Exact,
HasArguments,
};
inline StandaloneWiFiKey classifyStandaloneWiFiGet(const char* config) {
if (config == nullptr) return StandaloneWiFiKey::None;
if (strcmp(config, "wifi.ssid") == 0) return StandaloneWiFiKey::SSID;
if (strcmp(config, "wifi.status") == 0) return StandaloneWiFiKey::Status;
if (strcmp(config, "wifi.powersave") == 0) {
return StandaloneWiFiKey::PowerSave;
}
if (strcmp(config, "wifi.cli") == 0) return StandaloneWiFiKey::CLI;
return StandaloneWiFiKey::None;
}
inline StandaloneWiFiKey classifyStandaloneWiFiSet(const char* config,
const char** value) {
if (value != nullptr) *value = nullptr;
if (config == nullptr) return StandaloneWiFiKey::None;
struct Entry {
const char* name;
StandaloneWiFiKey key;
};
const Entry entries[] = {
{"wifi.ssid", StandaloneWiFiKey::SSID},
{"wifi.pwd", StandaloneWiFiKey::Password},
{"wifi.powersave", StandaloneWiFiKey::PowerSave},
{"wifi.cli", StandaloneWiFiKey::CLI},
};
for (const Entry& entry : entries) {
const size_t len = strlen(entry.name);
if (strncmp(config, entry.name, len) != 0) continue;
if (config[len] != 0 && config[len] != ' ') continue;
if (value != nullptr) {
*value = config[len] == ' ' ? config + len + 1 : config + len;
}
return entry.key;
}
return StandaloneWiFiKey::None;
}
inline bool standaloneWiFiSSIDValid(const char* value) {
if (value == nullptr) return false;
const size_t len = strlen(value);
return len >= 1 && len <= 31;
}
inline bool standaloneWiFiPasswordValid(const char* value) {
return value != nullptr && strlen(value) <= 63;
}
inline bool parseStandaloneWiFiPowerSave(const char* value,
uint8_t& power_save) {
if (value == nullptr) return false;
if (strcmp(value, "min") == 0) {
power_save = 0;
return true;
}
if (strcmp(value, "none") == 0) {
power_save = 1;
return true;
}
if (strcmp(value, "max") == 0) {
power_save = 2;
return true;
}
return false;
}
inline NoArgCommandMatch matchNoArgCommand(const char* command,
const char* expected) {
if (command == nullptr || expected == nullptr) {
return NoArgCommandMatch::NoMatch;
}
const size_t len = strlen(expected);
if (strncmp(command, expected, len) != 0) {
return NoArgCommandMatch::NoMatch;
}
if (command[len] == 0) return NoArgCommandMatch::Exact;
if (command[len] != ' ') return NoArgCommandMatch::NoMatch;
const char* rest = command + len;
while (*rest == ' ') rest++;
return *rest == 0 ? NoArgCommandMatch::Exact
: NoArgCommandMatch::HasArguments;
}
inline void formatUnknownSetting(char* reply, size_t capacity,
const char* setting) {
if (reply == nullptr || capacity == 0) return;
snprintf(reply, capacity, "Error: unknown setting: %s",
setting != nullptr ? setting : "");
}
// Mobile keyboards commonly capitalize the first word entered into a CLI
// field. Command verbs are identifiers, so normalize only that first token and
// leave every argument (names, passwords, keys, and other values) untouched.
+73 -10
View File
@@ -3016,9 +3016,49 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
}
return;
}
#endif
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
const char* cli_value = nullptr;
if (mesh::cli::classifyStandaloneWiFiSet(config, &cli_value) ==
mesh::cli::StandaloneWiFiKey::CLI) {
if (!_callbacks->setWiFiCLI(cli_value, reply)) {
strcpy(reply, "Error: WiFi CLI unavailable on this build");
}
return;
}
#endif
// Observer/MQTT/WiFi/timezone/alert/SNMP commands live in CommonCLI_Observer.cpp.
if (handleObserverSetCmd(sender_timestamp, config, reply)) return;
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && \
!defined(WEBCONFIG_DISABLED) && !defined(WITH_MQTT_BRIDGE)
const char* wifi_value = nullptr;
const mesh::cli::StandaloneWiFiKey wifi_key =
mesh::cli::classifyStandaloneWiFiSet(config, &wifi_value);
bool wifi_supported = true;
switch (wifi_key) {
case mesh::cli::StandaloneWiFiKey::SSID:
wifi_supported = _callbacks->setWiFiSSID(wifi_value, reply);
break;
case mesh::cli::StandaloneWiFiKey::Password:
wifi_supported = _callbacks->setWiFiPassword(wifi_value, reply);
break;
case mesh::cli::StandaloneWiFiKey::PowerSave:
wifi_supported = _callbacks->setWiFiPowerSave(wifi_value, reply);
break;
case mesh::cli::StandaloneWiFiKey::CLI:
wifi_supported = _callbacks->setWiFiCLI(wifi_value, reply);
break;
default:
wifi_supported = false;
break;
}
if (wifi_key != mesh::cli::StandaloneWiFiKey::None) {
if (!wifi_supported) {
strcpy(reply, "Error: standalone WiFi settings unavailable on this build");
}
return;
}
#endif
#if defined(PORTABLE_ESP32_RADIO_CLI)
// Portable WiFi-heavy images keep the controls needed to configure and
// diagnose their radio. The full parser remains available in the FULL image.
@@ -4063,22 +4103,45 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
}
return;
}
#endif
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
if (mesh::cli::classifyStandaloneWiFiGet(config) ==
mesh::cli::StandaloneWiFiKey::CLI) {
if (!_callbacks->getWiFiCLI(reply)) {
strcpy(reply, "Error: WiFi CLI unavailable on this build");
}
return;
}
#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;
// than MQTTPrefs, so expose the WiFi configuration through the role.
const mesh::cli::StandaloneWiFiKey wifi_key =
mesh::cli::classifyStandaloneWiFiGet(config);
bool wifi_supported = true;
switch (wifi_key) {
case mesh::cli::StandaloneWiFiKey::SSID:
wifi_supported = _callbacks->getWiFiSSID(reply);
break;
case mesh::cli::StandaloneWiFiKey::Status:
wifi_supported = _callbacks->getWiFiStatus(reply);
break;
case mesh::cli::StandaloneWiFiKey::PowerSave:
wifi_supported = _callbacks->getWiFiPowerSave(reply);
break;
case mesh::cli::StandaloneWiFiKey::CLI:
wifi_supported = _callbacks->getWiFiCLI(reply);
break;
default:
wifi_supported = false;
break;
}
if (strcmp(config, "wifi.status") == 0) {
if (!_callbacks->getWiFiStatus(reply)) {
strcpy(reply, "Error: WiFi status unavailable on this build");
if (wifi_key != mesh::cli::StandaloneWiFiKey::None) {
if (!wifi_supported) {
strcpy(reply, "Error: WiFi information unavailable on this build");
}
return;
}
@@ -4434,7 +4497,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
sprintf(reply, "> %d", (uint8_t)_prefs->reboot_interval);
}
} else {
sprintf(reply, "??: %s", config);
mesh::cli::formatUnknownSetting(reply, 160, config);
}
#endif
}
+28
View File
@@ -402,6 +402,34 @@ public:
(void)reply;
return false;
};
virtual bool getWiFiPowerSave(char* reply) const {
(void)reply;
return false;
};
virtual bool getWiFiCLI(char* reply) const {
(void)reply;
return false;
};
virtual bool setWiFiSSID(const char* value, char* reply) {
(void)value;
(void)reply;
return false;
};
virtual bool setWiFiPassword(const char* value, char* reply) {
(void)value;
(void)reply;
return false;
};
virtual bool setWiFiPowerSave(const char* value, char* reply) {
(void)value;
(void)reply;
return false;
};
virtual bool setWiFiCLI(const char* value, char* reply) {
(void)value;
(void)reply;
return false;
};
virtual int getQueueSize() {
return 0; // no op by default
+20 -7
View File
@@ -12,6 +12,7 @@
#include <Arduino.h>
#include "CommonCLI.h"
#include "CLICommandUtils.h"
#include "TxtDataHelpers.h"
#include "AlertReporter.h" // for alertReporterBannedChannelMatch[Hex]()
#include "MQTTObserverValidation.h" // pure input validators (host-testable)
@@ -21,6 +22,10 @@
#include <WiFiClientSecure.h>
#include <esp_wifi.h>
#endif
#if defined(ESP32_PLATFORM) && defined(ENABLE_OTA) && \
(defined(WIFI_OTA_SEEDER) || defined(WIFI_SSID))
#include "esp32/WiFiOtaSeeder.h"
#endif
#ifdef WITH_MQTT_BRIDGE
#include "bridges/MQTTBridge.h"
#include "MQTTConnectionPolicy.h" // classifySlotActivation() -- "will this slot connect here?"
@@ -321,14 +326,18 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
strcpy(reply, "OK - saved");
#endif
}
} else if (memcmp(config, "wifi.ssid ", 10) == 0) {
if (valueTooLong(&config[10], sizeof(_mqtt_prefs.wifi_ssid), reply, "wifi.ssid")) return true;
StrHelper::strncpy(_mqtt_prefs.wifi_ssid, &config[10], sizeof(_mqtt_prefs.wifi_ssid));
} else if (memcmp(config, "wifi.ssid ", 10) == 0
|| strcmp(config, "wifi.ssid") == 0) {
const char* value = config[9] == 0 ? config + 9 : config + 10;
if (valueTooLong(value, sizeof(_mqtt_prefs.wifi_ssid), reply, "wifi.ssid")) return true;
StrHelper::strncpy(_mqtt_prefs.wifi_ssid, value, sizeof(_mqtt_prefs.wifi_ssid));
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "wifi.pwd ", 9) == 0) {
if (valueTooLong(&config[9], sizeof(_mqtt_prefs.wifi_password), reply, "wifi.pwd")) return true;
StrHelper::strncpy(_mqtt_prefs.wifi_password, &config[9], sizeof(_mqtt_prefs.wifi_password));
} else if (memcmp(config, "wifi.pwd ", 9) == 0
|| strcmp(config, "wifi.pwd") == 0) {
const char* value = config[8] == 0 ? config + 8 : config + 9;
if (valueTooLong(value, sizeof(_mqtt_prefs.wifi_password), reply, "wifi.pwd")) return true;
StrHelper::strncpy(_mqtt_prefs.wifi_password, value, sizeof(_mqtt_prefs.wifi_password));
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "wifi.powersave ", 15) == 0) {
@@ -881,7 +890,7 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
} else if (memcmp(subcmd, "diag", 4) == 0) {
MQTTBridge::formatSlotDiagReply(reply, 160, slot);
} else {
sprintf(reply, "??: %s", config);
mesh::cli::formatUnknownSetting(reply, 160, config);
}
} else if (memcmp(config, "wifi.ssid", 9) == 0) {
sprintf(reply, "> %s", _mqtt_prefs.wifi_ssid);
@@ -948,6 +957,10 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
sprintf(reply, "> %s (code: %d)", status_str, status);
#endif
}
#if defined(ESP32_PLATFORM) && defined(ENABLE_OTA) && \
(defined(WIFI_OTA_SEEDER) || defined(WIFI_SSID))
mesh::ota::WiFiOtaSeeder::appendStatus(reply, 160);
#endif
} else if (memcmp(config, "wifi.powersave", 14) == 0) {
uint8_t ps = _mqtt_prefs.wifi_power_save;
const char* ps_name = (ps == 1) ? "none" : (ps == 2) ? "max" : "min";
+15
View File
@@ -96,3 +96,18 @@ static inline bool wcIsValidReqId(const char* reqid) {
}
return true;
}
// The browser terminal submits exactly one command through the existing
// 160-byte CLI buffer. Reject blank, oversized, or multiline input so a JSON
// request cannot smuggle a second command into the dispatcher.
static inline bool wcIsValidCliCommand(const char* command) {
if (command == NULL) return false;
const size_t len = strnlen(command, 161);
if (len == 0 || len > 159) return false;
bool has_text = false;
for (size_t i = 0; i < len; i++) {
if (command[i] == '\r' || command[i] == '\n') return false;
if (command[i] != ' ' && command[i] != '\t') has_text = true;
}
return has_text;
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
namespace WiFiOtaSeederStatus {
inline bool append(char* reply, size_t capacity, bool listening,
bool client_attached, uint16_t port) {
if (!reply || capacity == 0 || !listening) return false;
const size_t used = strnlen(reply, capacity);
if (used >= capacity) return false;
const int written = snprintf(
reply + used, capacity - used, ", OTA TCP %u: %s",
static_cast<unsigned>(port),
client_attached ? "client connected" : "listening");
return written >= 0 && static_cast<size_t>(written) < capacity - used;
}
} // namespace WiFiOtaSeederStatus
File diff suppressed because it is too large. Load diff
+363 -4
View File
@@ -12,6 +12,11 @@
#include <esp_system.h>
#include <esp_heap_caps.h>
#if defined(ESP32_PLATFORM) && defined(ENABLE_OTA) && \
(defined(WIFI_OTA_SEEDER) || defined(WIFI_SSID))
#include <helpers/esp32/WiFiOtaSeeder.h>
#endif
#ifdef WITH_MQTT_BRIDGE
#include <helpers/MQTTPrefs.h>
#include <helpers/MQTTPresets.h>
@@ -19,6 +24,7 @@
#endif
#include "WebConfigHtml.h"
#include "helpers/CLICommandUtils.h"
#include "helpers/WebConfigKeys.h"
// Placeholder sent instead of stored secrets; POSTs carrying it are dropped
@@ -50,6 +56,16 @@ static bool fixedTimeEquals(const char* a, const char* b, size_t max_len) {
return diff == 0;
}
static void appendOtaSeederStatus(char* reply, size_t reply_len) {
#if defined(ESP32_PLATFORM) && defined(ENABLE_OTA) && \
(defined(WIFI_OTA_SEEDER) || defined(WIFI_SSID))
mesh::ota::WiFiOtaSeeder::appendStatus(reply, reply_len);
#else
(void)reply;
(void)reply_len;
#endif
}
// RAII lock; tolerates a null mutex (allocation failure) by not locking.
struct WCLock {
SemaphoreHandle_t h;
@@ -68,6 +84,7 @@ WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool ow
: _cb(callbacks), _mqtt_prefs(mqtt_prefs), _owns_wifi(owns_wifi), _pub_key(pub_key),
_fw_ver(fw_ver), _role(role), _board_name(board_name) {
_mux = xSemaphoreCreateMutex();
_cli_enabled = loadCliEnabled(true);
#ifdef WITH_MQTT_BRIDGE
MQTTPrefs* obs = static_cast<MQTTPrefs*>(_mqtt_prefs);
@@ -112,6 +129,22 @@ bool WebConfigServer::saveEnabled(bool enabled) {
return written == sizeof(uint8_t);
}
bool WebConfigServer::loadCliEnabled(bool default_value) {
Preferences nvs;
if (!nvs.begin("mesh-webui", true)) return default_value;
bool enabled = nvs.getBool("cli", default_value);
nvs.end();
return enabled;
}
bool WebConfigServer::saveCliEnabled(bool enabled) {
Preferences nvs;
if (!nvs.begin("mesh-webui", false)) return false;
size_t written = nvs.putBool("cli", enabled);
nvs.end();
return written == sizeof(uint8_t);
}
bool WebConfigServer::loadStandaloneWiFi(char* ssid, size_t ssid_len,
char* password, size_t password_len,
uint8_t* power_save) {
@@ -124,14 +157,16 @@ bool WebConfigServer::loadStandaloneWiFi(char* ssid, size_t ssid_len,
String stored_password = nvs.getString("password", "");
uint8_t stored_ps = nvs.getUChar("powersave", 1);
nvs.end();
if (stored_ssid.length() == 0 || stored_ssid.length() >= ssid_len
|| stored_password.length() >= password_len) return false;
if (stored_ssid.length() >= ssid_len
|| stored_password.length() >= password_len) {
return false;
}
strncpy(ssid, stored_ssid.c_str(), ssid_len - 1);
ssid[ssid_len - 1] = 0;
strncpy(password, stored_password.c_str(), password_len - 1);
password[password_len - 1] = 0;
if (power_save) *power_save = stored_ps <= 2 ? stored_ps : 1;
return true;
return stored_ssid.length() != 0;
}
bool WebConfigServer::saveStandaloneWiFi(const char* ssid, const char* password,
@@ -151,6 +186,113 @@ bool WebConfigServer::saveStandaloneWiFi(const char* ssid, const char* password,
return ok;
}
bool WebConfigServer::setStandaloneWiFiSSID(const char* value, char* reply,
size_t reply_len) {
if (!reply || reply_len == 0) return false;
if (!mesh::cli::standaloneWiFiSSIDValid(value)) {
snprintf(reply, reply_len, "Error: WiFi SSID must be 1-31 characters");
return false;
}
Preferences nvs;
if (!nvs.begin("mesh-wifi", false)) {
snprintf(reply, reply_len, "Error: failed to open WiFi settings");
return false;
}
const bool ok = nvs.putString("ssid", value) == strlen(value);
nvs.end();
snprintf(reply, reply_len, ok ? "OK - WiFi SSID saved"
: "Error: failed to save WiFi SSID");
return ok;
}
bool WebConfigServer::setStandaloneWiFiPassword(const char* value, char* reply,
size_t reply_len) {
if (!reply || reply_len == 0) return false;
if (!mesh::cli::standaloneWiFiPasswordValid(value)) {
snprintf(reply, reply_len,
"Error: WiFi password must be at most 63 characters");
return false;
}
Preferences nvs;
if (!nvs.begin("mesh-wifi", false)) {
snprintf(reply, reply_len, "Error: failed to open WiFi settings");
return false;
}
nvs.putString("password", value);
const bool ok = nvs.getString("password", "\x01") == value;
nvs.end();
snprintf(reply, reply_len, ok ? "OK - WiFi password saved"
: "Error: failed to save WiFi password");
return ok;
}
bool WebConfigServer::setStandaloneWiFiPowerSave(const char* value, char* reply,
size_t reply_len) {
if (!reply || reply_len == 0) return false;
uint8_t power_save = 1;
if (!mesh::cli::parseStandaloneWiFiPowerSave(value, power_save)) {
snprintf(reply, reply_len, "Error: power save must be none, min, or max");
return false;
}
Preferences nvs;
if (!nvs.begin("mesh-wifi", false)) {
snprintf(reply, reply_len, "Error: failed to open WiFi settings");
return false;
}
const bool ok =
nvs.putUChar("powersave", power_save) == sizeof(uint8_t);
nvs.end();
if (!ok) {
snprintf(reply, reply_len, "Error: failed to save WiFi power save");
return false;
}
esp_err_t apply_result = ESP_OK;
if (WiFi.getMode() != WIFI_OFF) {
const wifi_ps_type_t ps_mode =
power_save == 1 ? WIFI_PS_NONE
: power_save == 2 ? WIFI_PS_MAX_MODEM
: WIFI_PS_MIN_MODEM;
apply_result = esp_wifi_set_ps(ps_mode);
}
if (apply_result == ESP_OK) {
snprintf(reply, reply_len, "OK - WiFi power save set to %s", value);
} else {
snprintf(reply, reply_len,
"OK - saved; WiFi power save applies on next connection");
}
return true;
}
bool WebConfigServer::setWiFiCliEnabled(const char* value, char* reply,
size_t reply_len) {
if (!reply || reply_len == 0) return false;
if (!value || (strcmp(value, "on") != 0 && strcmp(value, "off") != 0)) {
snprintf(reply, reply_len, "Error: use set wifi.cli on|off");
return false;
}
const bool enabled = strcmp(value, "on") == 0;
if (!saveCliEnabled(enabled)) {
snprintf(reply, reply_len, "Error: failed to save WiFi CLI setting");
return false;
}
WebConfigServer* active = _active;
if (active) active->_cli_enabled = enabled;
snprintf(reply, reply_len,
enabled ? "OK - WiFi CLI on; active with WiFi client"
: "OK - WiFi CLI off");
return true;
}
bool WebConfigServer::reloadStandaloneWiFi() {
return loadStandaloneWiFi(
_wifi_ssid, sizeof(_wifi_ssid),
_wifi_password, sizeof(_wifi_password), &_wifi_power_save);
}
bool WebConfigServer::formatWiFiSSID(char* reply, size_t reply_len) {
if (!reply || reply_len == 0) return false;
@@ -169,6 +311,46 @@ bool WebConfigServer::formatWiFiSSID(char* reply, size_t reply_len) {
return true;
}
bool WebConfigServer::formatWiFiPowerSave(char* reply, size_t reply_len) {
if (!reply || reply_len == 0) return false;
uint8_t power_save = 1;
if (_active) {
power_save = _active->_wifi_power_save;
} else {
char ssid[32] = "";
char password[64] = "";
loadStandaloneWiFi(
ssid, sizeof(ssid), password, sizeof(password), &power_save);
}
const char* name = "none";
if (power_save == 0) {
name = "min";
} else if (power_save == 2) {
name = "max";
}
snprintf(reply, reply_len, "> %s", name);
return true;
}
bool WebConfigServer::formatWiFiCliStatus(char* reply, size_t reply_len) {
if (!reply || reply_len == 0) return false;
WebConfigServer* active = _active;
const bool enabled = active ? active->_cli_enabled : loadCliEnabled(true);
const bool available = active && active->_cb->supportsCliTerminal();
const bool running = enabled && available && active->_mode == MODE_LAN
&& WiFi.status() == WL_CONNECTED;
if (!enabled) {
snprintf(reply, reply_len, "> off");
} else if (running) {
snprintf(reply, reply_len, "> on, active");
} else {
snprintf(reply, reply_len, "> on, waiting for WiFi client");
}
return true;
}
bool WebConfigServer::formatWiFiStatus(char* reply, size_t reply_len) {
if (!reply || reply_len == 0) return false;
@@ -189,23 +371,27 @@ bool WebConfigServer::formatWiFiStatus(char* reply, size_t reply_len) {
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);
appendOtaSeederStatus(reply, reply_len);
return true;
}
if (active && active->_mode == MODE_CONNECTING) {
snprintf(reply, reply_len, "> connecting, SSID: %s",
configured ? ssid : "(not configured)");
appendOtaSeederStatus(reply, reply_len);
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.SSID().c_str(),
WiFi.localIP().toString().c_str(), WiFi.RSSI());
appendOtaSeederStatus(reply, reply_len);
return true;
}
if (!configured) {
strcpy(reply, "> not configured; run 'start webconfig'");
appendOtaSeederStatus(reply, reply_len);
return true;
}
@@ -228,6 +414,7 @@ bool WebConfigServer::formatWiFiStatus(char* reply, size_t reply_len) {
}
break;
}
appendOtaSeederStatus(reply, reply_len);
return true;
}
@@ -419,6 +606,10 @@ void WebConfigServer::finalizeTeardown() {
_batch_state = BATCH_IDLE;
_batch_next = 0;
_batch_reboot_armed = false;
_cli_state = CLI_IDLE;
_cli_reqid[0] = 0;
_cli_command[0] = 0;
_cli_reply[0] = 0;
_session_token[0] = 0;
_stats_json[0] = 0;
if (_cb) _cb->onWebConfigStopped();
@@ -464,6 +655,7 @@ void WebConfigServer::tick(uint32_t now) {
if (_dns) _dns->processNextRequest();
if (_cli_state == CLI_PENDING) drainCliCommand();
if (_batch_state == BATCH_PENDING) drainBatch(now);
if (WebConfigBatch::rebootDue(_reboot_at, now)) {
@@ -493,6 +685,26 @@ void WebConfigServer::tick(uint32_t now) {
}
}
void WebConfigServer::drainCliCommand() {
char command[160];
{
WCLock lock(_mux);
if (_cli_state != CLI_PENDING) return;
strncpy(command, _cli_command, sizeof(command) - 1);
command[sizeof(command) - 1] = 0;
}
char reply[160] = "";
_cb->execAdminCommand(command, reply);
if (reply[0] == 0) strcpy(reply, "(no reply)");
WCLock lock(_mux);
if (_cli_state != CLI_PENDING) return;
strncpy(_cli_reply, reply, sizeof(_cli_reply) - 1);
_cli_reply[sizeof(_cli_reply) - 1] = 0;
_cli_state = CLI_DONE;
}
void WebConfigServer::drainBatch(uint32_t now) {
// One command per call, spaced out. Each `set` persists prefs with a flash
// write, and flash writes stall the WiFi task (flash cache off); running a
@@ -681,6 +893,13 @@ void WebConfigServer::registerRoutes() {
dispatchRequest(r, &WebConfigServer::handleConfigPost);
},
NULL, collectBody);
_server->on("/api/cli/result", HTTP_GET, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleCliResult);
});
_server->on("/api/cli", HTTP_POST, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleCliPost);
},
NULL, collectBody);
_server->on("/api/stats", HTTP_GET, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleStats);
});
@@ -788,6 +1007,8 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
doc["active_slots"] = 0;
#endif
doc["mqtt"] = has_mqtt;
doc["cli"] = _cli_enabled && _mode == MODE_LAN
&& WiFi.status() == WL_CONNECTED && _cb->supportsCliTerminal();
doc["capabilities"] = node.capabilities;
AsyncResponseStream* res = req->beginResponseStream("application/json");
@@ -961,6 +1182,11 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
JsonObject set = doc["set"];
WCLock lock(_mux);
if (_cli_state == CLI_PENDING) {
req->send(409, "application/json",
"{\"error\":\"CLI command is still running\"}");
return;
}
const WebConfigBatch::State bstate = toSpecState(_batch_state);
const bool reqid_matches = strcmp(reqid, _batch_reqid) == 0;
const WebConfigBatch::PostOutcome pre =
@@ -1135,6 +1361,139 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
req->send(res);
}
void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) {
if (_mode != MODE_LAN) {
req->send(403, "application/json",
"{\"error\":\"terminal requires LAN mode\"}");
return;
}
if (!_cb->supportsCliTerminal()) {
req->send(404, "application/json",
"{\"error\":\"terminal unavailable on this role\"}");
return;
}
if (!_cli_enabled) {
req->send(403, "application/json",
"{\"error\":\"terminal disabled; set wifi.cli on\"}");
return;
}
if (!checkAuth(req)) {
req->send(401, "application/json", "{\"error\":\"auth\"}");
return;
}
if (req->contentLength() > 512) {
req->send(413, "application/json", "{\"error\":\"body too large\"}");
return;
}
const char* body = (const char*)req->_tempObject;
DynamicJsonDocument doc(512);
if (!body || deserializeJson(doc, body) != DeserializationError::Ok) {
req->send(400, "application/json", "{\"error\":\"bad json\"}");
return;
}
const char* reqid = doc["reqid"] | "";
const char* command = doc["command"] | "";
if (!wcIsValidReqId(reqid)) {
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
return;
}
if (!wcIsValidCliCommand(command)) {
req->send(400, "application/json",
"{\"error\":\"command must be one nonblank line up to 159 bytes\"}");
return;
}
WCLock lock(_mux);
const bool same_request = strcmp(reqid, _cli_reqid) == 0;
if (_cli_state == CLI_PENDING || same_request) {
if (!same_request) {
StaticJsonDocument<96> busy;
busy["error"] = "busy";
busy["reqid"] = (const char*)_cli_reqid;
String out;
serializeJson(busy, out);
req->send(409, "application/json", out);
return;
}
StaticJsonDocument<96> replay;
replay["state"] = _cli_state == CLI_DONE ? "done" : "pending";
replay["reqid"] = (const char*)_cli_reqid;
String out;
serializeJson(replay, out);
req->send(202, "application/json", out);
return;
}
if (_batch_state == BATCH_PENDING) {
req->send(409, "application/json",
"{\"error\":\"configuration change is still running\"}");
return;
}
strncpy(_cli_reqid, reqid, sizeof(_cli_reqid) - 1);
_cli_reqid[sizeof(_cli_reqid) - 1] = 0;
strncpy(_cli_command, command, sizeof(_cli_command) - 1);
_cli_command[sizeof(_cli_command) - 1] = 0;
_cli_reply[0] = 0;
_cli_state = CLI_PENDING;
StaticJsonDocument<96> ack;
ack["state"] = "pending";
ack["reqid"] = (const char*)_cli_reqid;
String out;
serializeJson(ack, out);
req->send(202, "application/json", out);
}
void WebConfigServer::handleCliResult(AsyncWebServerRequest* req) {
if (_mode != MODE_LAN) {
req->send(403, "application/json",
"{\"error\":\"terminal requires LAN mode\"}");
return;
}
if (!_cb->supportsCliTerminal()) {
req->send(404, "application/json",
"{\"error\":\"terminal unavailable on this role\"}");
return;
}
if (!checkAuth(req)) {
req->send(401, "application/json", "{\"error\":\"auth\"}");
return;
}
if (!req->hasParam("reqid")) {
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
return;
}
String requested_reqid = req->getParam("reqid")->value();
if (!wcIsValidReqId(requested_reqid.c_str())) {
req->send(400, "application/json", "{\"error\":\"bad reqid\"}");
return;
}
WCLock lock(_mux);
if (_cli_state == CLI_IDLE) {
StaticJsonDocument<64> idle;
idle["state"] = "idle";
idle["reqid"] = requested_reqid;
String out;
serializeJson(idle, out);
req->send(200, "application/json", out);
return;
}
if (strcmp(requested_reqid.c_str(), _cli_reqid) != 0) {
req->send(404, "application/json", "{\"error\":\"unknown request\"}");
return;
}
DynamicJsonDocument doc(512);
doc["state"] = _cli_state == CLI_DONE ? "done" : "pending";
doc["reqid"] = (const char*)_cli_reqid;
if (_cli_state == CLI_DONE) doc["reply"] = (const char*)_cli_reply;
AsyncResponseStream* res = req->beginResponseStream("application/json");
serializeJson(doc, *res);
req->send(res);
}
void WebConfigServer::handleStats(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
if (!checkAuth(req)) { req->send(401, "application/json", "{\"error\":\"auth\"}"); return; }
+34 -3
View File
@@ -12,9 +12,9 @@
//
// 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.
// 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. Prefs-struct reads and request state are guarded by _mux.
//
// The master switch and standalone WiFi credentials use independent NVS
// namespaces, so role-specific prefs-file layouts remain untouched.
@@ -93,6 +93,12 @@ 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;
// Browser terminal commands use remote-admin semantics so serial-only
// secrets and destructive maintenance commands remain unavailable.
virtual bool supportsCliTerminal() const { return false; }
virtual void execAdminCommand(char* cmd, char* reply) {
execCommand(cmd, reply);
}
virtual void rebootNow() = 0;
// Bracket a config batch so bridge restarts triggered by individual
// `set` handlers can be coalesced into one.
@@ -126,13 +132,25 @@ public:
// 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);
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);
static bool setStandaloneWiFiPassword(const char* value, char* reply,
size_t reply_len);
static bool setStandaloneWiFiPowerSave(const char* value, char* reply,
size_t reply_len);
static bool setWiFiCliEnabled(const char* value, char* reply,
size_t reply_len);
static bool formatWiFiSSID(char* reply, size_t reply_len);
static bool formatWiFiStatus(char* reply, size_t reply_len);
static bool formatWiFiPowerSave(char* reply, size_t reply_len);
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 /
@@ -143,6 +161,7 @@ public:
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
bool reloadStandaloneWiFi();
void requestStop(); // stop listening and detach this session
void tick(uint32_t now); // call every loop iteration
@@ -155,6 +174,7 @@ private:
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 };
enum CliState : uint8_t { CLI_IDLE = 0, CLI_PENDING, CLI_DONE };
static WebConfigBatch::State toSpecState(BatchState state) {
switch (state) {
case BATCH_PENDING: return WebConfigBatch::State::Pending;
@@ -187,6 +207,7 @@ private:
char _wifi_ssid[32] = {0};
char _wifi_password[64] = {0};
uint8_t _wifi_power_save = 1;
bool _cli_enabled = true;
char _ap_ssid[33] = {0};
// Currently attached session, also used by the display's setup-info poll.
@@ -207,6 +228,13 @@ private:
bool _standalone_wifi_dirty = false;
BatchEntry _batch[MAX_BATCH];
// Browser terminal command: filled by async_tcp under _mux and executed by
// tick() on the loop task. Separate from the allowlisted config batch.
volatile CliState _cli_state = CLI_IDLE;
char _cli_reqid[24] = {0};
char _cli_command[160] = {0};
char _cli_reply[160] = {0};
// LAN-mode session (single slot; new login evicts the old session)
char _session_token[33] = {0};
uint32_t _session_last_seen = 0;
@@ -240,6 +268,7 @@ private:
void detachRoutes();
uint32_t handlerRefCount() const;
void drainBatch(uint32_t now);
void drainCliCommand();
void finalizeTeardown();
bool checkAuth(AsyncWebServerRequest* req);
static void collectBody(AsyncWebServerRequest* req, uint8_t* data, size_t len,
@@ -252,6 +281,8 @@ private:
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);
+7
View File
@@ -7,6 +7,7 @@
#include <WiFi.h>
#include <helpers/WiFiOtaSeederPolicy.h>
#include <helpers/WiFiOtaSeederStatus.h>
#include <helpers/ota/FolderMotaStore.h>
#include <helpers/ota/MotaSourceSerial.h>
#include <helpers/ota/OtaContext.h>
@@ -121,6 +122,12 @@ bool WiFiOtaSeeder::isAttached() {
return tcp_folder_attached;
}
bool WiFiOtaSeeder::appendStatus(char* reply, size_t capacity) {
return WiFiOtaSeederStatus::append(
reply, capacity, listener_active, tcp_folder_attached,
OTA_SEEDER_TCP_PORT);
}
} // namespace ota
} // namespace mesh
+2
View File
@@ -3,6 +3,7 @@
#if defined(ESP32_PLATFORM) && defined(ENABLE_OTA) && \
(defined(WIFI_OTA_SEEDER) || defined(WIFI_SSID))
#include <stddef.h>
#include <stdint.h>
#ifndef OTA_SEEDER_TCP_PORT
@@ -18,6 +19,7 @@ public:
static void stop();
static bool isListening();
static bool isAttached();
static bool appendStatus(char* reply, size_t capacity);
static uint16_t port() { return OTA_SEEDER_TCP_PORT; }
};
+1 -1
View File
@@ -25,7 +25,7 @@ does not reflect the GoogleTest count -- run the built binary directly
|-------|-------------------|--------|
| `test_mqtt_presets` | `src/helpers/MQTTPresets.h` | preset lookup; table integrity (unique names, non-empty URLs, JWT-audience invariant, names fit the slot buffer); `mqttPresetNeedsSlotCredentials`; slot-count constants |
| `test_observer_validation` | `src/helpers/MQTTObserverValidation.h` | IATA (exactly 3 alphanumerics), owner key (64 hex), NTP hostname, and the buffer-fit check behind the #17 length validation -- including boundaries and nulls |
| `test_webconfig_keys` | `src/helpers/WebConfigKeys.h` | POST-key allowlist, secret detection, admin-password classification/validation, slot-index bounds, and the short-key out-of-bounds guard (attacker-supplied keys) |
| `test_webconfig_keys` | `src/helpers/WebConfigKeys.h` | POST-key allowlist, secret detection, admin-password and browser-terminal command validation, slot-index bounds, and short-input guards |
| `test_topic_template` | `src/helpers/MQTTTopicTemplate.h` | `{iata}/{device}/{token}/{type}` expansion, overflow/NUL-termination, and a buffer-size fuzz |
| `test_mqtt_topic_router` | `src/helpers/MQTTTopicRouter.h` | complete preset/custom topic-routing contract; MeshRank packets-only behavior; required identifiers; invalid inputs/slots; exact buffer boundaries |
| `test_mqtt_connection_policy` | `src/helpers/MQTTConnectionPolicy.h` | reconnect guard/backoff/stagger and breaker transitions; stable reset; JWT lifetime/renewal policy; exact timing boundaries and 32-bit `millis()` rollover |
@@ -65,6 +65,101 @@ TEST(CLICommandUtils, RejectsNonDecimalOrOverflowValues) {
EXPECT_FLOAT_EQ(123.0f, value);
}
TEST(CLICommandUtils, ClassifiesStandaloneWiFiGetKeysExactly) {
using mesh::cli::StandaloneWiFiKey;
EXPECT_EQ(StandaloneWiFiKey::SSID,
mesh::cli::classifyStandaloneWiFiGet("wifi.ssid"));
EXPECT_EQ(StandaloneWiFiKey::Status,
mesh::cli::classifyStandaloneWiFiGet("wifi.status"));
EXPECT_EQ(StandaloneWiFiKey::PowerSave,
mesh::cli::classifyStandaloneWiFiGet("wifi.powersave"));
EXPECT_EQ(StandaloneWiFiKey::CLI,
mesh::cli::classifyStandaloneWiFiGet("wifi.cli"));
EXPECT_EQ(StandaloneWiFiKey::None,
mesh::cli::classifyStandaloneWiFiGet("wifi.pwd"));
EXPECT_EQ(StandaloneWiFiKey::None,
mesh::cli::classifyStandaloneWiFiGet("wifi.status.extra"));
}
TEST(CLICommandUtils, ClassifiesStandaloneWiFiSetKeysAndPreservesValues) {
using mesh::cli::StandaloneWiFiKey;
const char* value = nullptr;
EXPECT_EQ(StandaloneWiFiKey::SSID,
mesh::cli::classifyStandaloneWiFiSet(
"wifi.ssid Slow Fi", &value));
EXPECT_STREQ("Slow Fi", value);
EXPECT_EQ(StandaloneWiFiKey::Password,
mesh::cli::classifyStandaloneWiFiSet("wifi.pwd", &value));
EXPECT_STREQ("", value);
EXPECT_EQ(StandaloneWiFiKey::PowerSave,
mesh::cli::classifyStandaloneWiFiSet(
"wifi.powersave max", &value));
EXPECT_STREQ("max", value);
EXPECT_EQ(StandaloneWiFiKey::CLI,
mesh::cli::classifyStandaloneWiFiSet("wifi.cli on", &value));
EXPECT_STREQ("on", value);
EXPECT_EQ(StandaloneWiFiKey::None,
mesh::cli::classifyStandaloneWiFiSet(
"wifi.powersaver max", &value));
EXPECT_EQ(nullptr, value);
}
TEST(CLICommandUtils, ValidatesStandaloneWiFiValues) {
EXPECT_TRUE(mesh::cli::standaloneWiFiSSIDValid("SlowFi"));
EXPECT_TRUE(mesh::cli::standaloneWiFiSSIDValid(
"1234567890123456789012345678901"));
EXPECT_FALSE(mesh::cli::standaloneWiFiSSIDValid(""));
EXPECT_FALSE(mesh::cli::standaloneWiFiSSIDValid(
"12345678901234567890123456789012"));
EXPECT_TRUE(mesh::cli::standaloneWiFiPasswordValid(""));
EXPECT_TRUE(mesh::cli::standaloneWiFiPasswordValid(
"123456789012345678901234567890123456789012345678901234567890123"));
EXPECT_FALSE(mesh::cli::standaloneWiFiPasswordValid(
"1234567890123456789012345678901234567890123456789012345678901234"));
uint8_t power_save = 99;
EXPECT_TRUE(mesh::cli::parseStandaloneWiFiPowerSave("min", power_save));
EXPECT_EQ(0, power_save);
EXPECT_TRUE(mesh::cli::parseStandaloneWiFiPowerSave("none", power_save));
EXPECT_EQ(1, power_save);
EXPECT_TRUE(mesh::cli::parseStandaloneWiFiPowerSave("max", power_save));
EXPECT_EQ(2, power_save);
EXPECT_FALSE(mesh::cli::parseStandaloneWiFiPowerSave("off", power_save));
}
TEST(CLICommandUtils, MatchesDiscoverNeighborsWithoutPrefixCollisions) {
using mesh::cli::NoArgCommandMatch;
EXPECT_EQ(NoArgCommandMatch::Exact,
mesh::cli::matchNoArgCommand(
"discover.neighbors", "discover.neighbors"));
EXPECT_EQ(NoArgCommandMatch::Exact,
mesh::cli::matchNoArgCommand(
"discover.neighbors ", "discover.neighbors"));
EXPECT_EQ(NoArgCommandMatch::HasArguments,
mesh::cli::matchNoArgCommand(
"discover.neighbors now", "discover.neighbors"));
EXPECT_EQ(NoArgCommandMatch::NoMatch,
mesh::cli::matchNoArgCommand(
"discover.neighbors.extra", "discover.neighbors"));
}
TEST(CLICommandUtils, FormatsUsefulUnknownSettingErrors) {
char reply[64] = "";
mesh::cli::formatUnknownSetting(reply, sizeof(reply), "wifi.typo");
EXPECT_STREQ("Error: unknown setting: wifi.typo", reply);
EXPECT_EQ(nullptr, strstr(reply, "??:"));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
@@ -134,6 +134,32 @@ TEST(WebConfigKeys, RejectsMissingMalformedOrWrongLengthRequestIds) {
EXPECT_FALSE(wcIsValidReqId("01234567 9abcdef"));
}
// ---- browser terminal commands -------------------------------------------
TEST(WebConfigKeys, AcceptsSingleLineCliCommands) {
EXPECT_TRUE(wcIsValidCliCommand("get wifi.status"));
EXPECT_TRUE(wcIsValidCliCommand("set flood.max 12"));
EXPECT_TRUE(wcIsValidCliCommand(" neighbors "));
}
TEST(WebConfigKeys, RejectsBlankMultilineOrOversizedCliCommands) {
EXPECT_FALSE(wcIsValidCliCommand(NULL));
EXPECT_FALSE(wcIsValidCliCommand(""));
EXPECT_FALSE(wcIsValidCliCommand(" \t"));
EXPECT_FALSE(wcIsValidCliCommand("get role\nreboot"));
EXPECT_FALSE(wcIsValidCliCommand("get role\rreboot"));
char max_command[160];
memset(max_command, 'x', sizeof(max_command) - 1);
max_command[sizeof(max_command) - 1] = 0;
EXPECT_TRUE(wcIsValidCliCommand(max_command));
char oversized[161];
memset(oversized, 'x', sizeof(oversized) - 1);
oversized[sizeof(oversized) - 1] = 0;
EXPECT_FALSE(wcIsValidCliCommand(oversized));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
@@ -1,6 +1,7 @@
#include <gtest/gtest.h>
#include <helpers/WiFiOtaSeederPolicy.h>
#include <helpers/WiFiOtaSeederStatus.h>
namespace Policy = WiFiOtaSeederPolicy;
@@ -24,6 +25,34 @@ TEST(WiFiOtaSeederPolicy, DetectsExternalDetachOfTcpFolder) {
EXPECT_TRUE(Policy::tcpFolderWasDetached(true, false));
}
TEST(WiFiOtaSeederStatus, OmitsInactiveListener) {
char reply[64] = "> connected";
EXPECT_FALSE(WiFiOtaSeederStatus::append(
reply, sizeof(reply), false, false, 5001));
EXPECT_STREQ("> connected", reply);
}
TEST(WiFiOtaSeederStatus, ReportsListeningPort) {
char reply[64] = "> connected";
EXPECT_TRUE(WiFiOtaSeederStatus::append(
reply, sizeof(reply), true, false, 5001));
EXPECT_STREQ("> connected, OTA TCP 5001: listening", reply);
}
TEST(WiFiOtaSeederStatus, ReportsAttachedClient) {
char reply[64] = "> connected";
EXPECT_TRUE(WiFiOtaSeederStatus::append(
reply, sizeof(reply), true, true, 5001));
EXPECT_STREQ("> connected, OTA TCP 5001: client connected", reply);
}
TEST(WiFiOtaSeederStatus, TruncatesSafelyWhenReplyIsFull) {
char reply[20] = "> connected";
EXPECT_FALSE(WiFiOtaSeederStatus::append(
reply, sizeof(reply), true, false, 5001));
EXPECT_EQ('\0', reply[sizeof(reply) - 1]);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
+197 -3
View File
@@ -106,6 +106,17 @@ canvas{width:100%;height:56px;display:block}
.kv b{font-weight:600;text-align:right;overflow:hidden;text-overflow:ellipsis}
.hide{display:none!important}
.note{font-size:12.5px;color:var(--mut);background:var(--chip);border-radius:8px;padding:9px 11px;margin-bottom:13px}
.cli-out{min-height:260px;max-height:52vh;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;
background:#0b1016;color:#dce7f5;border:1px solid var(--line);border-radius:9px;padding:11px;
font:13px/1.45 ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;margin-bottom:10px}
.cli-mode{display:flex;gap:16px;margin:0 0 9px;font-size:13px;color:var(--mut)}
.cli-mode label{display:flex;align-items:center;gap:6px;cursor:pointer}
.cli-mode input{margin:0}
.cli-row{display:flex;align-items:flex-end;gap:8px}
.cli-row input,.cli-row textarea{flex:1;min-width:0;padding:9px 10px;font:16px ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;
color:var(--ink);background:var(--in-bg);border:1px solid var(--in-line);border-radius:8px;outline:none}
.cli-row textarea{width:100%;min-height:150px;resize:vertical}
.cli-row input:focus,.cli-row textarea:focus{border-color:var(--acc);box-shadow:0 0 0 2px color-mix(in srgb,var(--acc) 25%,transparent)}
</style>
</head>
<body>
@@ -235,6 +246,7 @@ canvas{width:100%;height:56px;display:block}
<button data-t="mqtt">MQTT</button>
<button data-t="wifi">WiFi</button>
<button data-t="stats">Stats</button>
<button data-t="cli">CLI</button>
</div>
<div id="t-radio">
@@ -397,6 +409,28 @@ canvas{width:100%;height:56px;display:block}
<div id="stat-slots" style="font-size:13.5px;color:var(--mut)">No data yet.</div>
</div>
</div>
<div id="t-cli" class="hide">
<div class="card">
<h2>Node terminal</h2>
<div class="note">Available only while connected as a WiFi client; control it with <b>set wifi.cli on|off</b>. Runs one administrator CLI command at a time. Commands that reboot the node or stop WebConfig can close this page before their reply arrives.</div>
<pre class="cli-out" id="cli-output">WebConfig CLI ready.
</pre>
<div class="cli-mode" role="radiogroup" aria-label="Command input mode">
<label><input type="radio" name="cli-mode" value="single" checked> Single command</label>
<label><input type="radio" name="cli-mode" value="block"> Command block</label>
</div>
<form id="cli-form" class="cli-row">
<input type="text" id="cli-command" maxlength="159" autocomplete="off"
autocapitalize="off" autocorrect="off" spellcheck="false"
placeholder="Enter a command">
<textarea id="cli-block" class="hide" maxlength="16000" rows="8"
autocapitalize="off" autocorrect="off" spellcheck="false"
placeholder="Enter one command per line (up to 100 commands)"></textarea>
<button class="btn" id="cli-send" type="submit">Send</button>
</form>
</div>
</div>
</section>
</main>
@@ -445,7 +479,7 @@ canvas{width:100%;height:56px;display:block}
<script>
"use strict";
var SENTINEL="********";
var st={mode:"",authed:false,hasMqtt:false,passwordSupported:false,needsPassword:false,caps:0,cfg:null,orig:{},dirty:{},presets:[],nslots:0,statsTimer:0,hist:{heap:[],noise:[]},scanTarget:"",scanTimer:0};
var st={mode:"",authed:false,hasMqtt:false,hasCli:false,passwordSupported:false,needsPassword:false,caps:0,cfg:null,orig:{},dirty:{},presets:[],nslots:0,statsTimer:0,hist:{heap:[],noise:[]},scanTarget:"",scanTimer:0};
function $(s){return document.querySelector(s)}
function $$(s){return Array.prototype.slice.call(document.querySelectorAll(s))}
@@ -490,6 +524,10 @@ function applyFeatures(){
var floodCard=$("#radio-flood-card");
if(floodCard)floodCard.classList.toggle("hide",!(st.caps&(128|256)));
$$(".password-card").forEach(function(el){el.classList.toggle("hide",!st.passwordSupported)});
if(!st.hasCli){
var cliTab=$('#tabs button[data-t="cli"]');if(cliTab)cliTab.remove();
$("#t-cli").classList.add("hide");
}
if(st.hasMqtt)return;
// One HTML asset serves every ESP32 role. The firmware reports whether it
@@ -510,7 +548,7 @@ function applyFeatures(){
/* ---------- boot ---------- */
function boot(){
api("/api/status").then(function(s){
st.mode=s.mode;st.authed=s.auth;st.hasMqtt=!!s.mqtt;st.passwordSupported=!!s.password_supported;st.needsPassword=!!s.needs_password;st.caps=s.capabilities||0;st.nslots=s.active_slots||s.runtime_slots||0;
st.mode=s.mode;st.authed=s.auth;st.hasMqtt=!!s.mqtt;st.hasCli=!!s.cli;st.passwordSupported=!!s.password_supported;st.needsPassword=!!s.needs_password;st.caps=s.capabilities||0;st.nslots=s.active_slots||s.runtime_slots||0;
$("#h-name").textContent=s.name||"MeshCore";
$("#h-sub").textContent=s.role+" * "+s.fw+" * "+s.board;
var b=$("#h-badge");b.classList.remove("hide");
@@ -825,8 +863,164 @@ function enterApp(){
$("#tabs").addEventListener("click",function(ev){
var b=ev.target.closest("button");if(!b)return;
$$("#tabs button").forEach(function(x){x.classList.toggle("on",x===b)});
["radio","mqtt","wifi","stats"].forEach(function(t){$("#t-"+t).classList.toggle("hide",t!==b.dataset.t)});
["radio","mqtt","wifi","stats","cli"].forEach(function(t){$("#t-"+t).classList.toggle("hide",t!==b.dataset.t)});
if(b.dataset.t==="stats")startStats();else stopStats();
if(b.dataset.t==="cli")setTimeout(focusCliInput,0);
});
/* ---------- CLI terminal ---------- */
var cliHistory=[],cliHistoryAt=0,cliRunning=false,cliQueue=[],cliQueueAt=0,cliBlockRun=false;
function appendCli(text){
var out=$("#cli-output");
out.textContent+=text;
if(out.textContent.length>12000)out.textContent=out.textContent.slice(-10000);
out.scrollTop=out.scrollHeight;
}
function isCliBlockMode(){
var selected=$('input[name="cli-mode"]:checked');
return selected&&selected.value==="block";
}
function focusCliInput(){
(isCliBlockMode()?$("#cli-block"):$("#cli-command")).focus();
}
function updateCliMode(){
var block=isCliBlockMode();
$("#cli-command").classList.toggle("hide",block);
$("#cli-block").classList.toggle("hide",!block);
$("#cli-send").textContent=block?"Run block":"Send";
focusCliInput();
}
function setCliRunning(running){
cliRunning=running;
$("#cli-command").disabled=running;
$("#cli-block").disabled=running;
$("#cli-send").disabled=running;
$$('input[name="cli-mode"]').forEach(function(input){input.disabled=running});
}
function cliByteLength(text){
if(typeof TextEncoder!=="undefined")return new TextEncoder().encode(text).length;
var bytes=encodeURIComponent(text).match(/%[0-9A-F]{2}|./g);
return bytes?bytes.length:0;
}
function parseCliBlock(text){
var lines=text.split(/\r\n|\r|\n/),commands=[];
for(var i=0;i<lines.length;i++){
if(!lines[i].trim())continue;
if(cliByteLength(lines[i])>159)throw new Error("line "+(i+1)+" exceeds 159 bytes");
commands.push(lines[i]);
if(commands.length>100)throw new Error("a block can contain at most 100 commands");
}
return commands;
}
function finishCliRun(completed){
var wasBlock=cliBlockRun,total=cliQueue.length;
cliQueue=[];
cliQueueAt=0;
cliBlockRun=false;
setCliRunning(false);
if(completed&&wasBlock){
appendCli("[block complete: "+total+" command"+(total===1?"":"s")+"]\n");
$("#cli-block").value="";
}
focusCliInput();
if(completed)loadConfigSoft();
}
function stopCliRun(message){
var remaining=cliQueue.length-cliQueueAt;
appendCli(message+"\n");
if(cliBlockRun&&remaining>0){
appendCli("[block stopped: "+remaining+" command"+(remaining===1?"":"s")+" not sent]\n");
}
finishCliRun(false);
}
function pollCli(reqid,tries,command){
api("/api/cli/result?reqid="+encodeURIComponent(reqid),{timeout:4000}).then(function(r){
if(r.reqid!==reqid)throw new Error("request mismatch");
if(r.state==="pending"){
setTimeout(function(){pollCli(reqid,tries,command)},150);
return;
}
if(r.state!=="done")throw new Error("result unavailable");
appendCli((r.reply||"(no reply)")+"\n");
if(/^set\s+wifi\.cli\s+off\s*$/i.test(command)){
appendCli("[terminal disabled]\n");
var remaining=cliQueue.length-cliQueueAt;
if(cliBlockRun&&remaining>0){
appendCli("[block stopped: "+remaining+" command"+(remaining===1?"":"s")+" not sent]\n");
}
finishCliRun(false);
setTimeout(function(){location.reload()},700);
}else{
runNextCliCommand();
}
}).catch(function(e){
if(e.message==="auth"){stopCliRun("[authentication required]");return}
if(tries<12){
setTimeout(function(){pollCli(reqid,tries+1,command)},400);
return;
}
stopCliRun("[reply unavailable - the command may have stopped WiFi, WebConfig, or rebooted the node]");
});
}
function runNextCliCommand(){
if(cliQueueAt>=cliQueue.length){
finishCliRun(true);
return;
}
var command=cliQueue[cliQueueAt++];
appendCli("> "+command+"\n");
var reqid=mkReqId();
post("/api/cli",{reqid:reqid,command:command}).then(function(){
pollCli(reqid,0,command);
}).catch(function(e){
stopCliRun("[error: "+e.message+"]");
});
}
$("#cli-form").addEventListener("submit",function(ev){
ev.preventDefault();
if(cliRunning)return;
var block=isCliBlockMode(),commands;
try{
commands=block?parseCliBlock($("#cli-block").value):[$("#cli-command").value];
if(!block&&cliByteLength(commands[0])>159)throw new Error("command exceeds 159 bytes");
}catch(e){
appendCli("[error: "+e.message+"]\n");
return;
}
commands=commands.filter(function(command){return command.trim()});
if(!commands.length)return;
commands.forEach(function(command){
cliHistory.push(command);
if(cliHistory.length>30)cliHistory.shift();
});
cliHistoryAt=cliHistory.length;
if(!block)$("#cli-command").value="";
cliQueue=commands;
cliQueueAt=0;
cliBlockRun=block;
if(block)appendCli("[running block: "+commands.length+" command"+(commands.length===1?"":"s")+"]\n");
setCliRunning(true);
runNextCliCommand();
});
$$('input[name="cli-mode"]').forEach(function(input){
input.addEventListener("change",updateCliMode);
});
$("#cli-command").addEventListener("keydown",function(ev){
if(ev.key!=="ArrowUp"&&ev.key!=="ArrowDown")return;
ev.preventDefault();
if(ev.key==="ArrowUp"&&cliHistoryAt>0)cliHistoryAt--;
if(ev.key==="ArrowDown"&&cliHistoryAt<cliHistory.length)cliHistoryAt++;
this.value=cliHistoryAt<cliHistory.length?cliHistory[cliHistoryAt]:"";
setTimeout(function(){
var input=$("#cli-command");
input.setSelectionRange(input.value.length,input.value.length);
},0);
});
$("#cli-block").addEventListener("keydown",function(ev){
if((ev.ctrlKey||ev.metaKey)&&ev.key==="Enter"){
ev.preventDefault();
$("#cli-form").requestSubmit();
}
});
/* ---------- stats ---------- */