Add WebConfig CLI and standalone WiFi controls
This commit is contained in:
1 parent
7bbde76380
commit
3f5e497ca6
24 files changed
+2698
-1224
No files matched your search
+70
-10
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
+1307
-1176
File diff suppressed because it is too large.
Load diff
@@ -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; }
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ---------- */
|
||||
|
||||
Reference in new issue
Block a user