mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-16 06:32:35 +00:00
Share the Companion terminal with the WiFi console
This commit is contained in:
+15
-14
@@ -491,23 +491,24 @@ 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.
|
||||
setup access point. When active, the WebConfig page has a **CLI** tab. Repeater
|
||||
and room-server commands use the authenticated administrator parser; commands
|
||||
restricted to a physical serial connection remain unavailable there.
|
||||
|
||||
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.
|
||||
Companion uses the same complete terminal as USB and Full Companion TCP port
|
||||
5002, including `card`, `import meshcore://...`, `list`, `to`, messages, remote
|
||||
login, and delayed replies. It uses the Companion's trusted LAN. See
|
||||
[Companion browser terminal](terminal_chat_cli.md#companion-wifi-browser-terminal)
|
||||
for contact import, terminal ownership, and disconnect behavior.
|
||||
|
||||
Type a command at the prompt, or paste multiple lines and confirm the sequence.
|
||||
Blank lines and comments are ignored. The device reports the supported batch
|
||||
limit. Companion sends each line after the previous command finishes and
|
||||
continues polling for incoming text after the sequence. `help` lists the
|
||||
device's actual terminal commands; Tab completes command names and settings.
|
||||
|
||||
The up/down arrow keys recall commands entered during the current browser
|
||||
session in single-command mode. Commands available in the terminal still
|
||||
session. 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
|
||||
|
||||
+10
-4
@@ -250,10 +250,16 @@ and the selected WiFi/MQTT mode keeps reconnecting. Other setup sessions retain
|
||||
their profile's idle timeout.
|
||||
|
||||
Every ESP32 build with WebConfig supports the browser command terminal,
|
||||
including WiFi Companion and Full Companion. Companions use their own bounded
|
||||
configuration commands on the trusted LAN; chat and streaming commands remain
|
||||
in the USB/TCP terminal. Repeater and Room Server use the authenticated admin
|
||||
parser.
|
||||
including WiFi Companion and Full Companion. Companions use their complete
|
||||
USB/TCP terminal on the trusted LAN, including contact import, chat, recipient
|
||||
selection, and delayed replies. Repeater and Room Server use the authenticated
|
||||
admin parser.
|
||||
|
||||
To add a Companion contact, run `import meshcore://<full-contact-card-data>`,
|
||||
then `list` to confirm it appears. Get the complete card with `card` on the
|
||||
other Companion. A public key alone is insufficient. See the
|
||||
[terminal command guide](terminal_chat_cli.md#companion-wifi-browser-terminal)
|
||||
for `to`, `send`, `login`, and `cmd` examples and session ownership.
|
||||
|
||||
The saved `wifi.cli` setting defaults to `on`. Use `set wifi.cli off` to disable
|
||||
the **CLI** tab.
|
||||
|
||||
@@ -231,9 +231,12 @@ The browser accepts Companion configuration commands, including `get powersaving
|
||||
`set usb.logging on|off`, WiFi controls, and MQTT settings. `set mqtt.enabled on|off` controls MQTT
|
||||
without erasing broker settings; `get mqtt.enabled`, `get mqtt.running`, and
|
||||
`get mqtt.status` distinguish the saved switch from current connections.
|
||||
`set logging.output off|usb|wifi|both` selects both outputs together. Chat, recipient
|
||||
selection, streaming output, and USB MOTA session commands use the complete
|
||||
Companion text terminal over USB or TCP port 5002.
|
||||
`set logging.output off|usb|wifi|both` selects both outputs together. The browser
|
||||
also uses the complete Companion terminal for `card`, `import meshcore://...`,
|
||||
`list`, `to`, `send`, `login`, `cmd`, and delayed replies, with the same syntax
|
||||
as USB and TCP port 5002. See the [terminal command guide](terminal_chat_cli.md)
|
||||
for contact import and session ownership. USB MOTA session commands require
|
||||
the USB connection; WiFi host folders use port 5001.
|
||||
|
||||
On the two primary-ESP-NOW Full targets, the same terminal also provides
|
||||
`get espnow.channel` and `set espnow.channel <1-13>`. A channel change is
|
||||
|
||||
@@ -31,7 +31,10 @@ Companion mode, send this line once and wait for the terminal banner:
|
||||
```
|
||||
|
||||
Run `help`, `board`, and `version` to inspect the device. On ESP32 you can
|
||||
alternatively connect to its text terminal with `nc DEVICE_IP 5002`.
|
||||
alternatively open the node's LAN address and select **CLI**, or connect with
|
||||
`nc DEVICE_IP 5002`. Both use the same terminal commands as USB, including
|
||||
`import meshcore://...`, `list`, `to`, and `send`. Use `disconnect` to release
|
||||
a browser/TCP session. See [the terminal guide](terminal_chat_cli.md).
|
||||
When finished, send `+++MESHCORE-TERM-STOP`, close the terminal, and reconnect
|
||||
your Companion app. These are local text commands; an app's custom-variable
|
||||
editor does not necessarily forward them.
|
||||
|
||||
@@ -39,8 +39,10 @@ The linked console runs in your computer's browser over **USB**. It does not
|
||||
need node WiFi or `set webui on`. **WebConfig** is a separate settings website
|
||||
served by supported ESP32 images. Both infrastructure and WiFi Companion
|
||||
WebConfig include a browser command terminal (`set wifi.cli on`), enabled by
|
||||
default on the LAN. Full ESP32 Companion also has a text terminal at TCP port **5002**
|
||||
for its complete chat and streaming command surface.
|
||||
default on the LAN. The Companion browser CLI uses the same terminal commands
|
||||
as USB, including contact import, chat, recipient selection, and streaming
|
||||
replies. Full ESP32 Companion also exposes this terminal at TCP port **5002**.
|
||||
See the [terminal command guide](terminal_chat_cli.md#companion-wifi-browser-terminal).
|
||||
|
||||
## Which old variant setting should I use?
|
||||
|
||||
|
||||
@@ -2,6 +2,42 @@
|
||||
|
||||
Below are the commands you can enter into the Terminal Chat clients:
|
||||
|
||||
## Companion WiFi browser terminal
|
||||
|
||||
On ESP32 WiFi Companions with WebConfig, open the node's LAN IP address and
|
||||
select **CLI**. The browser runs the same commands as the USB terminal and
|
||||
Full Companion's TCP terminal on port 5002, including contact import, contact
|
||||
selection, messages, remote login/commands, and delayed RF replies. Run `help`
|
||||
to list commands compiled into the device. Enable the tab with
|
||||
`set wifi.cli on`; the open setup AP does not expose it.
|
||||
|
||||
For example, copy the complete `meshcore://...` string from `card` on another
|
||||
Companion, then run these commands on the receiving Companion:
|
||||
|
||||
```text
|
||||
import meshcore://<full-contact-card-data>
|
||||
list
|
||||
to <contact-name-or-prefix>
|
||||
```
|
||||
|
||||
Replace the placeholders; a bare public key is not a contact card. Import is
|
||||
queued for validation, so use `list` to confirm the contact appears. `to`
|
||||
remains selected for subsequent `send`, `path`, `login`, and `cmd` commands
|
||||
within this terminal session.
|
||||
|
||||
Only one USB, TCP, or browser text terminal owns the session at a time. An
|
||||
idle startup USB prompt can be borrowed by WiFi. Use `disconnect` or the
|
||||
browser's **disconnect** button to release it. Closing the page releases the
|
||||
session; if the connection disappears, it expires after 60 seconds without a
|
||||
poll. Long replies page into the browser automatically, and incoming replies
|
||||
continue appearing after a command finishes. Terminal scrollback starts at
|
||||
4 KiB and can grow to 32 KiB for unread output, preferring PSRAM; it shrinks
|
||||
after reading and is freed when the session expires. If output exceeds the
|
||||
available buffer while unread, the browser reports the missing output.
|
||||
|
||||
USB protocol-switch and USB MOTA ownership commands still apply to the USB
|
||||
connection. Use the WiFi MOTA seeder on port 5001 for a host folder over WiFi.
|
||||
|
||||
## Companion USB mode
|
||||
|
||||
An ordinary Companion USB build starts in the normal binary Companion protocol
|
||||
|
||||
@@ -3370,9 +3370,9 @@ void MyMesh::onConfigBatchEnd() {
|
||||
}
|
||||
|
||||
void MyMesh::execAdminCommand(char* cmd, char* reply) {
|
||||
// WebConfig runs this bounded dispatcher on the mesh loop. It must not
|
||||
// enter the stateful USB/TCP terminal, select a recipient, or capture that
|
||||
// terminal's stream. Use remote-call semantics for board-specific commands.
|
||||
// The settings-batch API runs independently from the interactive terminal.
|
||||
// The browser CLI uses runStreamTerminal() for the shared USB/TCP session;
|
||||
// a settings batch must not capture or reset that session's stream.
|
||||
reply[0] = 0;
|
||||
if (!cmd || (strlen(cmd) > 2 && cmd[2] == '|')) {
|
||||
strcpy(reply, "Error: enter a plain CLI command");
|
||||
@@ -6810,7 +6810,7 @@ void MyMesh::printTerminalBanner(bool show_binary_stop) {
|
||||
if (show_binary_stop) {
|
||||
output.print(" (+++MESHCORE-TERM-STOP returns to Binary mode)\r\n");
|
||||
} else {
|
||||
output.print(" (disconnect to close this TCP terminal)\r\n");
|
||||
output.print(" (disconnect to close this network terminal)\r\n");
|
||||
}
|
||||
output.print("\r\n> ");
|
||||
}
|
||||
@@ -6828,9 +6828,9 @@ void MyMesh::exitTerminalMode() {
|
||||
if (_terminal_output == &mesh::usbTerminalPort()) _terminal_output = NULL;
|
||||
}
|
||||
|
||||
#if COMPANION_FEATURE_NETWORK_TERMINAL
|
||||
#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)
|
||||
bool MyMesh::enterNetworkTerminalMode(Stream& output) {
|
||||
if (_terminal_mode) return false;
|
||||
if (_terminal_mode || (_terminal_output != NULL && _terminal_output != &output)) return false;
|
||||
_terminal_output = &output;
|
||||
resetTerminalSession();
|
||||
printTerminalBanner(false);
|
||||
@@ -7983,7 +7983,7 @@ void MyMesh::handleTerminalCommand(char* command) {
|
||||
if (_terminal_mode) {
|
||||
terminalOutput().print(" +++MESHCORE-TERM-STOP\r\n");
|
||||
} else {
|
||||
terminalOutput().print(" disconnect (closes the TCP terminal)\r\n");
|
||||
terminalOutput().print(" disconnect (closes the network terminal)\r\n");
|
||||
}
|
||||
} else if (handleCommand(command, 0, local_reply)) {
|
||||
// Fill the same safe shared-command surface for getters (`get name`,
|
||||
|
||||
@@ -187,6 +187,15 @@ public:
|
||||
void execCommand(char* cmd, char* reply) override;
|
||||
bool supportsCliTerminal() const override { return true; }
|
||||
void execAdminCommand(char* cmd, char* reply) override;
|
||||
#ifdef ENABLE_USB_INTERFACE
|
||||
bool supportsStreamTerminal() const override { return true; }
|
||||
bool beginStreamTerminal(Stream& output) override;
|
||||
bool ownsStreamTerminal(const Stream& output) const override {
|
||||
return isNetworkTerminalMode(output);
|
||||
}
|
||||
void runStreamTerminal(char* command) override { handleTerminalCommand(command); }
|
||||
void endStreamTerminal(Stream& output) override;
|
||||
#endif
|
||||
void rebootNow() override;
|
||||
void onConfigBatchStart() override;
|
||||
void onConfigBatchEnd() override;
|
||||
@@ -206,10 +215,13 @@ public:
|
||||
void resetTerminalSession();
|
||||
bool isTerminalMode() const { return _terminal_mode; }
|
||||
void handleTerminalCommand(char* command);
|
||||
#if COMPANION_FEATURE_NETWORK_TERMINAL
|
||||
#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)
|
||||
bool enterNetworkTerminalMode(Stream& output);
|
||||
void exitNetworkTerminalMode(Stream& output);
|
||||
bool isNetworkTerminalMode(const Stream& output) const;
|
||||
bool isAnyNetworkTerminalMode() const {
|
||||
return !_terminal_mode && _terminal_output != NULL;
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ MultiSerialInterface interface_manager;
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if COMPANION_FEATURE_NETWORK_TERMINAL
|
||||
#if defined(ENABLE_USB_INTERFACE) && (COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG))
|
||||
static bool isNetworkTerminalActive();
|
||||
#endif
|
||||
|
||||
@@ -867,7 +867,7 @@ static void serviceUsbTerminal() {
|
||||
#else
|
||||
false,
|
||||
#endif
|
||||
#if COMPANION_FEATURE_NETWORK_TERMINAL
|
||||
#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)
|
||||
isNetworkTerminalActive()
|
||||
#else
|
||||
false
|
||||
@@ -1047,6 +1047,47 @@ static void expireUsbBinaryStartupProbeBeforeDispatch() {
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if defined(ENABLE_USB_INTERFACE) && (COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG))
|
||||
static bool isNetworkTerminalActive() {
|
||||
return the_mesh.isAnyNetworkTerminalMode();
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(WITH_WEBCONFIG) && defined(ENABLE_USB_INTERFACE)
|
||||
static mesh::UsbTcpTerminalHandoff browser_usb_handoff;
|
||||
|
||||
bool MyMesh::beginStreamTerminal(Stream& output) {
|
||||
static_assert(mesh::kTerminalCommandCapacity == MAX_TRANS_UNIT * 2 + 32,
|
||||
"Browser and USB/TCP command capacities must match");
|
||||
if (isAnyNetworkTerminalMode()) return false;
|
||||
const bool ascii_selected = isTerminalMode();
|
||||
const bool input_idle = usb_terminal_line_len == 0 && !usb_terminal_discard_line
|
||||
#if defined(COMPANION_RADIO_FULL)
|
||||
&& !usb_binary_startup_probe.isActive()
|
||||
#endif
|
||||
;
|
||||
if (!browser_usb_handoff.begin(ascii_selected,
|
||||
hasObservableActiveUsbTerminalClient(), input_idle,
|
||||
usb_serial_interface.getCompletedFrameCount())) return false;
|
||||
if (ascii_selected) leaveUsbTerminalMode(false);
|
||||
if (enterNetworkTerminalMode(output)) return true;
|
||||
if (browser_usb_handoff.shouldRestoreAscii(
|
||||
usb_serial_interface.getCompletedFrameCount())) enterUsbTerminalMode();
|
||||
return false;
|
||||
}
|
||||
|
||||
void MyMesh::endStreamTerminal(Stream& output) {
|
||||
const bool owned = isNetworkTerminalMode(output);
|
||||
exitNetworkTerminalMode(output);
|
||||
if (!owned) {
|
||||
browser_usb_handoff.cancel();
|
||||
} else if (browser_usb_handoff.shouldRestoreAscii(
|
||||
usb_serial_interface.getCompletedFrameCount())) {
|
||||
enterUsbTerminalMode();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
void halt() {
|
||||
while (1) ;
|
||||
}
|
||||
@@ -1641,12 +1682,6 @@ void halt() {
|
||||
static mesh::UsbTcpTerminalHandoff ota_console_usb_handoff;
|
||||
#endif
|
||||
|
||||
#if COMPANION_FEATURE_NETWORK_TERMINAL
|
||||
static bool isNetworkTerminalActive() {
|
||||
return the_mesh.isNetworkTerminalMode(ota_console_client);
|
||||
}
|
||||
#endif
|
||||
|
||||
static void ota_console_clear_line() {
|
||||
memset(ota_console_line, 0, sizeof(ota_console_line));
|
||||
ota_console_len = 0;
|
||||
|
||||
@@ -75,6 +75,13 @@ def requirements(platform, defines, target):
|
||||
parts["mqtt_connections_buffers"] = 24576
|
||||
if "ENABLE_OTA" in defines:
|
||||
parts["ota_source_scratch"] = 8192
|
||||
if (companion and "ENABLE_USB_INTERFACE" in defines and "WIFI_SSID" in defines
|
||||
and "WEBCONFIG_DISABLED" not in defines):
|
||||
parts["browser_terminal_session"] = 2048
|
||||
if "BOARD_HAS_PSRAM" not in defines:
|
||||
# Larger replies grow on demand only while preserving 32 KiB
|
||||
# of free internal heap, and shrink after the browser reads.
|
||||
parts["browser_terminal_scrollback"] = 4096
|
||||
if not companion:
|
||||
parts["neighbor_history"] = integer(defines, "MAX_RECENT_REPEATERS", 50) * 12
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
// The USB and TCP input buffers hold MAX_TRANS_UNIT * 2 + 32 bytes. Contact
|
||||
// cards need almost twice a radio packet's size after hexadecimal encoding.
|
||||
static const size_t kTerminalCommandCapacity = 255 * 2 + 32;
|
||||
static const size_t kTerminalInitialOutputCapacity = 4096;
|
||||
static const size_t kTerminalOutputCapacity = 32768;
|
||||
|
||||
// A bounded scrollback with absolute cursors. Reads do not consume data, so a
|
||||
// lost HTTP response can be fetched again without skipping or repeating text.
|
||||
// The caller serializes access and supplies storage only for a live session.
|
||||
class TerminalOutputBuffer {
|
||||
char* _data;
|
||||
size_t _capacity;
|
||||
uint64_t _end = 0;
|
||||
uint64_t _first = 0;
|
||||
|
||||
public:
|
||||
TerminalOutputBuffer(char* data, size_t capacity)
|
||||
: _data(data), _capacity(capacity) {}
|
||||
uint64_t end() const { return _end; }
|
||||
void clear() { _end = _first = 0; }
|
||||
void rebind(char* data, size_t capacity) {
|
||||
const uint64_t first = _end > capacity && _end - capacity > _first
|
||||
? _end - capacity : _first;
|
||||
for (uint64_t i = first; i < _end; ++i) data[i % capacity] = _data[i % _capacity];
|
||||
_data = data;
|
||||
_capacity = capacity;
|
||||
_first = first;
|
||||
}
|
||||
void append(const uint8_t* data, size_t len) {
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
if (_capacity) _data[_end % _capacity] = data[i];
|
||||
++_end;
|
||||
}
|
||||
if (_end > _capacity && _end - _capacity > _first) _first = _end - _capacity;
|
||||
}
|
||||
size_t read(uint64_t& cursor, char* out, size_t size, bool& lost) const {
|
||||
const uint64_t first = _first;
|
||||
lost = cursor < first;
|
||||
if (cursor < first) cursor = first;
|
||||
if (cursor > _end) cursor = _end;
|
||||
size_t n = 0;
|
||||
while (n + 1 < size && cursor < _end) {
|
||||
out[n++] = _data[cursor++ % _capacity];
|
||||
}
|
||||
// JSON strings must contain complete UTF-8 characters even when a page
|
||||
// boundary lands inside a multibyte message or contact name.
|
||||
if (n) {
|
||||
size_t lead = n - 1;
|
||||
while (lead > 0 && (static_cast<uint8_t>(out[lead]) & 0xC0) == 0x80) --lead;
|
||||
const uint8_t byte = static_cast<uint8_t>(out[lead]);
|
||||
const size_t width = byte >= 0xF0 ? 4 : byte >= 0xE0 ? 3 : byte >= 0xC0 ? 2 : 1;
|
||||
if (n - lead < width) { cursor -= n - lead; n = lead; }
|
||||
}
|
||||
if (size) out[n] = 0;
|
||||
return n;
|
||||
}
|
||||
};
|
||||
|
||||
// Exactly one command can be pending. A monotonically increasing sequence
|
||||
// makes retries idempotent even after later commands have completed.
|
||||
class TerminalCommandQueue {
|
||||
public:
|
||||
enum Result { Accepted, Replay, Busy, OutOfOrder, Invalid };
|
||||
char command[kTerminalCommandCapacity] = {0};
|
||||
uint32_t sequence = 0;
|
||||
bool pending = false;
|
||||
|
||||
static bool valid(const char* text, size_t length) {
|
||||
return text && length && length < kTerminalCommandCapacity
|
||||
&& strlen(text) == length && !memchr(text, '\r', length)
|
||||
&& !memchr(text, '\n', length);
|
||||
}
|
||||
|
||||
Result submit(uint32_t seq, const char* text, size_t length) {
|
||||
if (!valid(text, length)) return Invalid;
|
||||
if (seq == 0) return OutOfOrder;
|
||||
if (seq <= sequence) return Replay;
|
||||
if (pending) return Busy;
|
||||
if (seq != sequence + 1 || sequence == UINT32_MAX) return OutOfOrder;
|
||||
memcpy(command, text, length);
|
||||
command[length] = 0;
|
||||
sequence = seq;
|
||||
pending = true;
|
||||
return Accepted;
|
||||
}
|
||||
void finish() {
|
||||
memset(command, 0, sizeof(command));
|
||||
pending = false;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace mesh
|
||||
+2110
-2019
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,9 @@ static_assert(sizeof(WEBCONFIG_AP_PREFIX) <= 28,
|
||||
#endif
|
||||
|
||||
#include "WebConfigHtml.h"
|
||||
#include "WebTerminalStream.h"
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <new>
|
||||
#include "helpers/CLICommandUtils.h"
|
||||
#include "helpers/UsbLogging.h"
|
||||
#include "helpers/WebConfigKeys.h"
|
||||
@@ -392,6 +395,7 @@ WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool ow
|
||||
|
||||
WebConfigServer::~WebConfigServer() {
|
||||
detachRoutes();
|
||||
closeTerminal();
|
||||
if (_mux) vSemaphoreDelete(_mux);
|
||||
}
|
||||
|
||||
@@ -964,6 +968,7 @@ void WebConfigServer::requestStop() {
|
||||
}
|
||||
|
||||
void WebConfigServer::finalizeTeardown() {
|
||||
closeTerminal();
|
||||
// Async requests keep a pointer to their server until disconnect. Retain the
|
||||
// listener and route table for the firmware lifetime; only detach and reclaim
|
||||
// the per-session state.
|
||||
@@ -1028,6 +1033,8 @@ void WebConfigServer::tick(uint32_t now) {
|
||||
}
|
||||
if (_mode == MODE_OFF) return;
|
||||
|
||||
serviceTerminal(now);
|
||||
|
||||
// A primary ESP-NOW radio and its WiFi station cannot remain on different
|
||||
// channels. Reject a router-driven channel move (for example a CSA) and put
|
||||
// the mesh radio back on its boot channel before any reconnect work runs.
|
||||
@@ -1540,6 +1547,12 @@ void WebConfigServer::registerRoutes() {
|
||||
dispatchRequest(r, &WebConfigServer::handleCliPost);
|
||||
},
|
||||
NULL, collectBody);
|
||||
_server->on("/api/terminal", HTTP_GET, [](AsyncWebServerRequest* r) {
|
||||
dispatchRequest(r, &WebConfigServer::handleTerminalOutput);
|
||||
});
|
||||
_server->on("/api/terminal", HTTP_POST, [](AsyncWebServerRequest* r) {
|
||||
dispatchRequest(r, &WebConfigServer::handleTerminalPost);
|
||||
}, NULL, collectBody);
|
||||
_server->on("/api/stats", HTTP_GET, [](AsyncWebServerRequest* r) {
|
||||
dispatchRequest(r, &WebConfigServer::handleStats);
|
||||
});
|
||||
@@ -1692,6 +1705,8 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
|
||||
doc["wifi_psk64"] = (_mqtt_prefs == NULL || !_owns_wifi);
|
||||
doc["cli"] = _cli_enabled && _mode == MODE_LAN
|
||||
&& WiFi.status() == WL_CONNECTED && _cb->supportsCliTerminal();
|
||||
doc["terminal_stream"] = _cb->supportsStreamTerminal();
|
||||
doc["terminal_max_command"] = mesh::kTerminalCommandCapacity - 1;
|
||||
doc["capabilities"] = node.capabilities;
|
||||
// Commands the terminal may submit at once. The CLI shares the config
|
||||
// batch's fixed slot, so the cap is MAX_BATCH -- reported rather than
|
||||
@@ -2160,6 +2175,168 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
|
||||
// path once the operator has read the results. The rest (clkreboot, poweroff,
|
||||
// ota update) do real work on the way down and cannot be faked, so they run
|
||||
// normally and the connection drops -- the UI warns before sending them.
|
||||
void WebConfigServer::closeTerminal() {
|
||||
if (!_terminal) return;
|
||||
if (_terminal->attached) _cb->endStreamTerminal(*_terminal);
|
||||
delete _terminal;
|
||||
_terminal = NULL;
|
||||
}
|
||||
|
||||
void WebConfigServer::serviceTerminal(uint32_t now) {
|
||||
WCLock lock(_mux);
|
||||
if (!_terminal) return;
|
||||
if (now - _terminal->last_seen > (_terminal->closed ? 5000UL : 60000UL)) {
|
||||
closeTerminal();
|
||||
return;
|
||||
}
|
||||
if (_terminal->closed) return;
|
||||
if (_mode != MODE_LAN || !_cli_enabled || WiFi.status() != WL_CONNECTED
|
||||
|| (_terminal->attached && !_cb->ownsStreamTerminal(*_terminal))) {
|
||||
_terminal->println("\r\nERROR: terminal disconnected");
|
||||
if (_terminal->attached) _cb->endStreamTerminal(*_terminal);
|
||||
_terminal->attached = false;
|
||||
_terminal->closed = true;
|
||||
_terminal->queue.finish();
|
||||
return;
|
||||
}
|
||||
if (!_terminal->queue.pending || _batch_state == BATCH_PENDING) return;
|
||||
if (!_terminal->attached) {
|
||||
if (!_cb->beginStreamTerminal(*_terminal)) {
|
||||
_terminal->println("ERROR: another USB or TCP client owns the terminal; close it and retry");
|
||||
_terminal->closed = true;
|
||||
_terminal->queue.finish();
|
||||
return;
|
||||
}
|
||||
_terminal->attached = true;
|
||||
}
|
||||
char* command = _terminal->queue.command;
|
||||
while (*command == ' ' || *command == '\t') ++command;
|
||||
mesh::cli::normalizeCommandVerb(command);
|
||||
if (strcmp(command, "disconnect") == 0) {
|
||||
_terminal->println(" OK - disconnecting");
|
||||
_cb->endStreamTerminal(*_terminal);
|
||||
_terminal->attached = false;
|
||||
_terminal->closed = true;
|
||||
} else {
|
||||
_cb->runStreamTerminal(command);
|
||||
_terminal->print("> ");
|
||||
}
|
||||
_terminal->queue.finish();
|
||||
}
|
||||
|
||||
bool WebConfigServer::checkTerminalAccess(AsyncWebServerRequest* req) {
|
||||
if (_mode != MODE_LAN || !_cli_enabled) {
|
||||
req->send(403, "application/json", "{\"error\":\"terminal requires LAN mode and wifi.cli on\"}");
|
||||
return false;
|
||||
}
|
||||
if (!_cb->supportsStreamTerminal()) {
|
||||
req->send(404, "application/json", "{\"error\":\"stream terminal unavailable\"}");
|
||||
return false;
|
||||
}
|
||||
if (!checkAuth(req)) {
|
||||
req->send(401, "application/json", "{\"error\":\"auth\"}");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void WebConfigServer::handleTerminalPost(AsyncWebServerRequest* req) {
|
||||
if (!checkTerminalAccess(req)) return;
|
||||
if (!req->_tempObject || req->contentLength() > MAX_BODY) {
|
||||
req->send(413, "application/json", "{\"error\":\"body too large\"}");
|
||||
return;
|
||||
}
|
||||
DynamicJsonDocument doc(2048);
|
||||
if (deserializeJson(doc, static_cast<const char*>(req->_tempObject))) {
|
||||
req->send(400, "application/json", "{\"error\":\"bad json\"}");
|
||||
return;
|
||||
}
|
||||
const char* session = doc["session"] | "";
|
||||
JsonString command = doc["command"].as<JsonString>();
|
||||
if (!wcIsValidReqId(session) || !doc["seq"].is<uint32_t>() || command.isNull()) {
|
||||
req->send(400, "application/json", "{\"error\":\"invalid session or command\"}");
|
||||
return;
|
||||
}
|
||||
const uint32_t sequence = doc["seq"].as<uint32_t>();
|
||||
if (!sequence || !mesh::TerminalCommandQueue::valid(command.c_str(), command.size())) {
|
||||
req->send(400, "application/json", "{\"error\":\"command must be one line, at most 541 bytes\"}");
|
||||
return;
|
||||
}
|
||||
WCLock lock(_mux);
|
||||
if (_terminal && strcmp(session, _terminal->session) != 0) {
|
||||
if (!_terminal->closed || _terminal->attached) {
|
||||
req->send(409, "application/json", "{\"error\":\"another browser owns the terminal\"}");
|
||||
return;
|
||||
}
|
||||
delete _terminal;
|
||||
_terminal = NULL;
|
||||
}
|
||||
if (!_terminal) {
|
||||
// A vanished session must never silently start over and resend a message.
|
||||
if (sequence != 1) {
|
||||
req->send(409, "application/json", "{\"error\":\"terminal session expired\"}");
|
||||
return;
|
||||
}
|
||||
_terminal = new (std::nothrow) WebTerminalStream;
|
||||
if (!_terminal || !_terminal->ready()) {
|
||||
delete _terminal;
|
||||
_terminal = NULL;
|
||||
req->send(503, "application/json", "{\"error\":\"not enough free memory for terminal\"}");
|
||||
return;
|
||||
}
|
||||
strlcpy(_terminal->session, session, sizeof(_terminal->session));
|
||||
}
|
||||
if (_terminal->closed) {
|
||||
req->send(409, "application/json", "{\"error\":\"terminal session closed\"}");
|
||||
return;
|
||||
}
|
||||
const auto result = _terminal->queue.submit(sequence, command.c_str(), command.size());
|
||||
if (result != mesh::TerminalCommandQueue::Accepted
|
||||
&& result != mesh::TerminalCommandQueue::Replay) {
|
||||
const bool invalid = result == mesh::TerminalCommandQueue::Invalid;
|
||||
req->send(invalid ? 400 : 409, "application/json", invalid
|
||||
? "{\"error\":\"command must be one line, at most 541 bytes\"}"
|
||||
: "{\"error\":\"terminal busy or command out of order\"}");
|
||||
return;
|
||||
}
|
||||
_terminal->last_seen = millis();
|
||||
req->send(202, "application/json", "{\"accepted\":true}");
|
||||
}
|
||||
|
||||
void WebConfigServer::handleTerminalOutput(AsyncWebServerRequest* req) {
|
||||
if (!checkTerminalAccess(req)) return;
|
||||
const String session = req->hasParam("session") ? req->getParam("session")->value() : "";
|
||||
const String after = req->hasParam("after") ? req->getParam("after")->value() : "0";
|
||||
if (!wcIsValidReqId(session.c_str()) || !after.length() || after.length() > 16
|
||||
|| strspn(after.c_str(), "0123456789") != after.length()) {
|
||||
req->send(400, "application/json", "{\"error\":\"invalid session or cursor\"}");
|
||||
return;
|
||||
}
|
||||
WCLock lock(_mux);
|
||||
if (!_terminal || session != _terminal->session) {
|
||||
req->send(404, "application/json", "{\"error\":\"terminal session expired\"}");
|
||||
return;
|
||||
}
|
||||
uint64_t cursor = strtoull(after.c_str(), NULL, 10);
|
||||
char output[1025];
|
||||
bool lost = false;
|
||||
const size_t n = _terminal->readOutput(cursor, output, sizeof(output), lost);
|
||||
_terminal->last_seen = millis();
|
||||
DynamicJsonDocument doc(1536);
|
||||
doc["output"] = output;
|
||||
doc["cursor"] = cursor;
|
||||
doc["lost"] = lost;
|
||||
// A page may end a few bytes early to keep UTF-8 intact.
|
||||
doc["more"] = n >= sizeof(output) - 4;
|
||||
doc["seq"] = _terminal->queue.sequence;
|
||||
doc["done"] = !_terminal->queue.pending;
|
||||
doc["closed"] = _terminal->closed;
|
||||
AsyncResponseStream* response = req->beginResponseStream("application/json");
|
||||
response->addHeader("Cache-Control", "no-store");
|
||||
serializeJson(doc, *response);
|
||||
req->send(response);
|
||||
}
|
||||
|
||||
void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) {
|
||||
if (_mode != MODE_LAN) {
|
||||
req->send(403, "application/json",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
#include <freertos/semphr.h>
|
||||
#include <helpers/CompanionWiFiStatus.h>
|
||||
#include <helpers/WebConfigBatch.h>
|
||||
#include <helpers/TerminalSession.h>
|
||||
#include <helpers/WiFiPowerSave.h>
|
||||
#include <helpers/WiFiReconnectPolicy.h>
|
||||
#include <helpers/ui/DisplayBuildFlags.h>
|
||||
@@ -36,6 +37,7 @@
|
||||
class AsyncWebServer;
|
||||
class AsyncWebServerRequest;
|
||||
class DNSServer;
|
||||
class WebTerminalStream;
|
||||
|
||||
#ifndef WEBCONFIG_AP_IDLE_TIMEOUT_MS
|
||||
#if defined(MESHCORE_EXPANDED_PARTITION_PROFILE)
|
||||
@@ -139,6 +141,13 @@ public:
|
||||
virtual void execAdminCommand(char* cmd, char* reply) {
|
||||
execCommand(cmd, reply);
|
||||
}
|
||||
// Companion's browser uses the same stateful Stream terminal as USB/TCP.
|
||||
// All session callbacks run on the mesh loop, never the HTTP task.
|
||||
virtual bool supportsStreamTerminal() const { return false; }
|
||||
virtual bool beginStreamTerminal(Stream& output) { return false; }
|
||||
virtual bool ownsStreamTerminal(const Stream& output) const { return false; }
|
||||
virtual void runStreamTerminal(char* command) {}
|
||||
virtual void endStreamTerminal(Stream& output) {}
|
||||
virtual void rebootNow() = 0;
|
||||
// Bracket a config batch so bridge restarts triggered by individual
|
||||
// `set` handlers can be coalesced into one.
|
||||
@@ -296,6 +305,7 @@ private:
|
||||
uint32_t _setup_wifi_handoff_deadline = 0;
|
||||
char _setup_wifi_handoff_ip[16] = {0};
|
||||
BatchEntry _batch[MAX_BATCH];
|
||||
WebTerminalStream* _terminal = NULL;
|
||||
|
||||
// LAN-mode session (single slot; new login evicts the old session)
|
||||
char _session_token[33] = {0};
|
||||
@@ -330,6 +340,8 @@ private:
|
||||
void detachRoutes();
|
||||
uint32_t handlerRefCount() const;
|
||||
void drainBatch(uint32_t now);
|
||||
void serviceTerminal(uint32_t now);
|
||||
void closeTerminal();
|
||||
void serviceSetupWiFiHandoff(uint32_t now);
|
||||
void finishBatch(uint32_t now);
|
||||
void finalizeTeardown();
|
||||
@@ -347,6 +359,9 @@ private:
|
||||
void handleConfigResult(AsyncWebServerRequest* req);
|
||||
void handleCliPost(AsyncWebServerRequest* req);
|
||||
void handleCliResult(AsyncWebServerRequest* req);
|
||||
void handleTerminalPost(AsyncWebServerRequest* req);
|
||||
void handleTerminalOutput(AsyncWebServerRequest* req);
|
||||
bool checkTerminalAccess(AsyncWebServerRequest* req);
|
||||
void handleStats(AsyncWebServerRequest* req);
|
||||
void handleScan(AsyncWebServerRequest* req);
|
||||
void handlePresets(AsyncWebServerRequest* req);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <esp_heap_caps.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <helpers/TerminalSession.h>
|
||||
|
||||
// Allocated only while a browser terminal is open. PSRAM is preferred; boards
|
||||
// without it keep at least 32 KiB of internal heap after allocating scrollback.
|
||||
// Large unread replies can grow the buffer; draining them returns it to 4 KiB.
|
||||
class WebTerminalStream : public Stream {
|
||||
char* _storage;
|
||||
size_t _capacity = mesh::kTerminalInitialOutputCapacity;
|
||||
uint64_t _acknowledged = 0;
|
||||
SemaphoreHandle_t _lock;
|
||||
mesh::TerminalOutputBuffer _output;
|
||||
|
||||
static char* allocate(size_t capacity) {
|
||||
void* data = heap_caps_malloc(capacity,
|
||||
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (!data && heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)
|
||||
>= capacity + 32768) {
|
||||
data = heap_caps_malloc(capacity,
|
||||
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||
}
|
||||
return static_cast<char*>(data);
|
||||
}
|
||||
|
||||
void resize(size_t capacity) {
|
||||
char* storage = allocate(capacity);
|
||||
if (!storage) return;
|
||||
_output.rebind(storage, capacity);
|
||||
free(_storage);
|
||||
_storage = storage;
|
||||
_capacity = capacity;
|
||||
}
|
||||
|
||||
public:
|
||||
char session[24] = {0};
|
||||
mesh::TerminalCommandQueue queue;
|
||||
bool attached = false;
|
||||
bool closed = false;
|
||||
uint32_t last_seen = 0;
|
||||
|
||||
WebTerminalStream() : _storage(allocate(mesh::kTerminalInitialOutputCapacity)),
|
||||
_lock(xSemaphoreCreateMutex()),
|
||||
_output(_storage, _storage ? mesh::kTerminalInitialOutputCapacity : 0) {}
|
||||
~WebTerminalStream() override {
|
||||
free(_storage);
|
||||
if (_lock) vSemaphoreDelete(_lock);
|
||||
}
|
||||
bool ready() const { return _storage && _lock; }
|
||||
using Print::write;
|
||||
size_t write(uint8_t byte) override { return write(&byte, 1); }
|
||||
size_t write(const uint8_t* data, size_t len) override {
|
||||
if (!ready()) return 0;
|
||||
xSemaphoreTake(_lock, portMAX_DELAY);
|
||||
size_t wanted = _capacity;
|
||||
const uint64_t unread = _output.end() - _acknowledged + len;
|
||||
while (wanted < unread && wanted < mesh::kTerminalOutputCapacity) wanted *= 2;
|
||||
if (wanted > _capacity) resize(wanted);
|
||||
_output.append(data, len);
|
||||
xSemaphoreGive(_lock);
|
||||
return len;
|
||||
}
|
||||
size_t readOutput(uint64_t& cursor, char* out, size_t size, bool& lost) {
|
||||
xSemaphoreTake(_lock, portMAX_DELAY);
|
||||
if (cursor <= _output.end() && cursor > _acknowledged) _acknowledged = cursor;
|
||||
if (_capacity > mesh::kTerminalInitialOutputCapacity
|
||||
&& _output.end() - _acknowledged <= mesh::kTerminalInitialOutputCapacity) {
|
||||
resize(mesh::kTerminalInitialOutputCapacity);
|
||||
}
|
||||
const size_t n = _output.read(cursor, out, size, lost);
|
||||
xSemaphoreGive(_lock);
|
||||
return n;
|
||||
}
|
||||
int available() override { return 0; }
|
||||
int read() override { return -1; }
|
||||
int peek() override { return -1; }
|
||||
void flush() override {}
|
||||
};
|
||||
@@ -66,6 +66,16 @@ def esp_fixture(path, modern=False, fragmented=False):
|
||||
|
||||
|
||||
class FirmwareRamTest(unittest.TestCase):
|
||||
def test_browser_terminal_reserves_internal_session_and_psram_aware_scrollback(self):
|
||||
defines = {"ENABLE_USB_INTERFACE": 1, "WIFI_SSID": "", "DISPLAY_CLASS": "SSD1306Display"}
|
||||
base = ram.requirements("ESP32_PLATFORM", {**defines, "WEBCONFIG_DISABLED": 1}, "v4_companion")
|
||||
plain = ram.requirements("ESP32_PLATFORM", defines, "v4_companion")
|
||||
psram = ram.requirements("ESP32_PLATFORM", {**defines, "BOARD_HAS_PSRAM": 1}, "v4_companion")
|
||||
self.assertEqual(plain["required_heap_bytes"] - base["required_heap_bytes"], 6144)
|
||||
self.assertEqual(psram["required_heap_bytes"] - base["required_heap_bytes"], 2048)
|
||||
repeater = ram.requirements("ESP32_PLATFORM", defines, "v4_repeater")
|
||||
self.assertNotIn("browser_terminal_session", repeater["components"])
|
||||
|
||||
def test_longer_display_previews_reserve_heap_and_contiguous_history(self):
|
||||
defines = {"DISPLAY_CLASS": "SSD1306Display", "UI_SMALL_MESSAGE_FONT": 0}
|
||||
base = ram.requirements("ESP32_PLATFORM", defines, "v4_companion")
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/TerminalSession.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
TEST(TerminalSession, AcceptsFullContactCardsAndErasesCommandAfterExecution) {
|
||||
mesh::TerminalCommandQueue q;
|
||||
const std::string card = "import meshcore://" + std::string(510, 'a');
|
||||
ASSERT_EQ(mesh::TerminalCommandQueue::Accepted, q.submit(1, card.c_str(), card.size()));
|
||||
EXPECT_STREQ(card.c_str(), q.command);
|
||||
q.finish();
|
||||
EXPECT_FALSE(q.pending);
|
||||
for (char byte : q.command) EXPECT_EQ(0, byte);
|
||||
}
|
||||
|
||||
TEST(TerminalSession, RetriesNeverExecuteAgainIncludingAfterLaterCommands) {
|
||||
mesh::TerminalCommandQueue q;
|
||||
EXPECT_EQ(mesh::TerminalCommandQueue::Accepted, q.submit(1, "send hello", 10));
|
||||
EXPECT_EQ(mesh::TerminalCommandQueue::Replay, q.submit(1, "send hello", 10));
|
||||
q.finish();
|
||||
EXPECT_EQ(mesh::TerminalCommandQueue::Accepted, q.submit(2, "list", 4));
|
||||
q.finish();
|
||||
EXPECT_EQ(mesh::TerminalCommandQueue::Replay, q.submit(1, "send hello", 10));
|
||||
EXPECT_FALSE(q.pending);
|
||||
}
|
||||
|
||||
TEST(TerminalSession, RejectsConcurrentAndOutOfOrderCommandsWithoutChangingQueue) {
|
||||
mesh::TerminalCommandQueue q;
|
||||
EXPECT_EQ(mesh::TerminalCommandQueue::OutOfOrder, q.submit(2, "list", 4));
|
||||
EXPECT_EQ(mesh::TerminalCommandQueue::Accepted, q.submit(1, "card", 4));
|
||||
EXPECT_EQ(mesh::TerminalCommandQueue::Busy, q.submit(2, "list", 4));
|
||||
EXPECT_STREQ("card", q.command);
|
||||
q.finish();
|
||||
EXPECT_EQ(mesh::TerminalCommandQueue::OutOfOrder, q.submit(3, "list", 4));
|
||||
EXPECT_EQ(mesh::TerminalCommandQueue::OutOfOrder, q.submit(0, "list", 4));
|
||||
}
|
||||
|
||||
TEST(TerminalSession, ValidatesLengthAndRejectsEmbeddedCommands) {
|
||||
mesh::TerminalCommandQueue q;
|
||||
const std::string limit(mesh::kTerminalCommandCapacity - 1, 'x');
|
||||
const std::string oversized(mesh::kTerminalCommandCapacity, 'x');
|
||||
EXPECT_EQ(mesh::TerminalCommandQueue::Invalid, q.submit(1, oversized.c_str(), oversized.size()));
|
||||
EXPECT_EQ(mesh::TerminalCommandQueue::Invalid, q.submit(1, "list\nreboot", 11));
|
||||
EXPECT_EQ(mesh::TerminalCommandQueue::Invalid, q.submit(1, "list\rreboot", 11));
|
||||
EXPECT_EQ(mesh::TerminalCommandQueue::Invalid, q.submit(1, "list\0reboot", 11));
|
||||
EXPECT_EQ(mesh::TerminalCommandQueue::Invalid, q.submit(1, "", 0));
|
||||
EXPECT_EQ(mesh::TerminalCommandQueue::Accepted, q.submit(1, limit.c_str(), limit.size()));
|
||||
}
|
||||
|
||||
TEST(TerminalSession, PagesAFull350ContactListAndAllowsReadRetries) {
|
||||
std::vector<char> storage(mesh::kTerminalOutputCapacity);
|
||||
mesh::TerminalOutputBuffer buffer(storage.data(), storage.size());
|
||||
std::string input;
|
||||
for (int i = 0; i < 350; ++i) input += " " + std::string(31, 'n') + " (Repeater) - 123 days ago\r\n";
|
||||
buffer.append(reinterpret_cast<const uint8_t*>(input.data()), input.size());
|
||||
std::string actual;
|
||||
uint64_t cursor = 0;
|
||||
bool lost;
|
||||
char page[1025], retry[1025];
|
||||
while (cursor < buffer.end()) {
|
||||
uint64_t old = cursor;
|
||||
const size_t n = buffer.read(cursor, page, sizeof(page), lost);
|
||||
ASSERT_FALSE(lost);
|
||||
ASSERT_GT(n, 0u);
|
||||
EXPECT_EQ(n, buffer.read(old, retry, sizeof(retry), lost));
|
||||
EXPECT_STREQ(page, retry);
|
||||
actual.append(page, n);
|
||||
}
|
||||
EXPECT_EQ(input, actual);
|
||||
}
|
||||
|
||||
TEST(TerminalSession, ReportsExpiredOutputAndNeverWritesOutsideCapacity) {
|
||||
char storage[6] = {'!', 0, 0, 0, 0, '!'};
|
||||
mesh::TerminalOutputBuffer buffer(storage + 1, 4);
|
||||
buffer.append(reinterpret_cast<const uint8_t*>("abcdef"), 6);
|
||||
char out[8];
|
||||
uint64_t cursor = 0;
|
||||
bool lost;
|
||||
EXPECT_EQ(4u, buffer.read(cursor, out, sizeof(out), lost));
|
||||
EXPECT_TRUE(lost);
|
||||
EXPECT_STREQ("cdef", out);
|
||||
EXPECT_EQ('!', storage[0]);
|
||||
EXPECT_EQ('!', storage[5]);
|
||||
EXPECT_EQ(0u, buffer.read(cursor, out, sizeof(out), lost));
|
||||
EXPECT_FALSE(lost);
|
||||
}
|
||||
|
||||
TEST(TerminalSession, KeepsUtf8IntactAcrossOutputPages) {
|
||||
char storage[32];
|
||||
mesh::TerminalOutputBuffer buffer(storage, sizeof(storage));
|
||||
const std::string input = "ab\xF0\x9F\x98\x80" "cd\xC3\xA9" "ef";
|
||||
buffer.append(reinterpret_cast<const uint8_t*>(input.data()), input.size());
|
||||
char page[6];
|
||||
uint64_t cursor = 0;
|
||||
bool lost;
|
||||
EXPECT_EQ(2u, buffer.read(cursor, page, sizeof(page), lost));
|
||||
EXPECT_STREQ("ab", page);
|
||||
std::string actual(page);
|
||||
while (cursor < buffer.end()) {
|
||||
const size_t n = buffer.read(cursor, page, sizeof(page), lost);
|
||||
ASSERT_GT(n, 0u);
|
||||
actual.append(page, n);
|
||||
}
|
||||
EXPECT_EQ(input, actual);
|
||||
}
|
||||
|
||||
TEST(TerminalSession, GrowingAndShrinkingPreservesUnreadTextAndAbsoluteCursors) {
|
||||
char small[4], large[12], reduced[4], out[16];
|
||||
mesh::TerminalOutputBuffer buffer(small, sizeof(small));
|
||||
buffer.append(reinterpret_cast<const uint8_t*>("abcdef"), 6);
|
||||
buffer.rebind(large, sizeof(large));
|
||||
buffer.append(reinterpret_cast<const uint8_t*>("ghijkl"), 6);
|
||||
uint64_t cursor = 0;
|
||||
bool lost;
|
||||
EXPECT_EQ(10u, buffer.read(cursor, out, sizeof(out), lost));
|
||||
EXPECT_TRUE(lost);
|
||||
EXPECT_STREQ("cdefghijkl", out);
|
||||
buffer.rebind(reduced, sizeof(reduced));
|
||||
cursor = 8;
|
||||
EXPECT_EQ(4u, buffer.read(cursor, out, sizeof(out), lost));
|
||||
EXPECT_FALSE(lost);
|
||||
EXPECT_STREQ("ijkl", out);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Run the production terminal allocator/Stream against failing heap fixtures."""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class WebTerminalStreamTest(unittest.TestCase):
|
||||
def test_growth_drain_failure_and_release(self):
|
||||
headers = {
|
||||
"Arduino.h": """#pragma once
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
class Print { public: virtual ~Print() {};
|
||||
virtual size_t write(uint8_t)=0;
|
||||
virtual size_t write(const uint8_t*,size_t)=0;
|
||||
};
|
||||
class Stream: public Print { public:
|
||||
virtual int available()=0; virtual int read()=0; virtual int peek()=0;
|
||||
virtual void flush()=0;
|
||||
};
|
||||
""",
|
||||
"esp_heap_caps.h": """#pragma once
|
||||
#define MALLOC_CAP_SPIRAM 1
|
||||
#define MALLOC_CAP_INTERNAL 2
|
||||
#define MALLOC_CAP_8BIT 4
|
||||
void* heap_caps_malloc(size_t,int);
|
||||
size_t heap_caps_get_free_size(int);
|
||||
""",
|
||||
"freertos/FreeRTOS.h": "#pragma once\n#define portMAX_DELAY 0\n",
|
||||
"freertos/semphr.h": """#pragma once
|
||||
using SemaphoreHandle_t=void*;
|
||||
inline void* xSemaphoreCreateMutex(){return reinterpret_cast<void*>(1);}
|
||||
inline void xSemaphoreTake(void*,int){}
|
||||
inline void xSemaphoreGive(void*){}
|
||||
inline void vSemaphoreDelete(void*){}
|
||||
""",
|
||||
}
|
||||
code = r"""
|
||||
#include <cassert>
|
||||
#include <cstdlib>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <esp_heap_caps.h>
|
||||
bool psram = true;
|
||||
size_t internal_free = 0;
|
||||
std::map<void*,size_t> allocated;
|
||||
void* heap_caps_malloc(size_t n,int caps) {
|
||||
if (caps & MALLOC_CAP_SPIRAM) { if (!psram) return nullptr; }
|
||||
else if(n>internal_free) return nullptr;
|
||||
void* p=std::malloc(n); if(p) allocated[p]=n; return p;
|
||||
}
|
||||
size_t heap_caps_get_free_size(int){return internal_free;}
|
||||
void tracked_free(void* p){allocated.erase(p);std::free(p);}
|
||||
#define free tracked_free
|
||||
#include <helpers/esp32/WebTerminalStream.h>
|
||||
#undef free
|
||||
size_t bytes(){size_t n=0;for(auto& p:allocated)n+=p.second;return n;}
|
||||
int main(){
|
||||
{
|
||||
WebTerminalStream terminal;
|
||||
assert(terminal.ready() && bytes()==4096);
|
||||
std::string data(24000,'x');
|
||||
assert(terminal.write(reinterpret_cast<const uint8_t*>(data.data()),data.size())==data.size());
|
||||
assert(bytes()==32768);
|
||||
std::string result; uint64_t cursor=0; char chunk[1025]; bool lost;
|
||||
while(cursor<data.size()) {
|
||||
size_t n=terminal.readOutput(cursor,chunk,sizeof(chunk),lost);
|
||||
assert(n && !lost);result.append(chunk,n);
|
||||
}
|
||||
terminal.readOutput(cursor,chunk,sizeof(chunk),lost);
|
||||
assert(result==data && bytes()==4096);
|
||||
}
|
||||
assert(allocated.empty());
|
||||
psram=false;internal_free=8192;
|
||||
{WebTerminalStream t;assert(!t.ready());assert(t.write('x')==0);}
|
||||
assert(allocated.empty());
|
||||
internal_free=40960;
|
||||
{
|
||||
WebTerminalStream t;assert(t.ready() && bytes()==4096);
|
||||
std::string data(24000,'a');t.write(reinterpret_cast<const uint8_t*>(data.data()),data.size());
|
||||
assert(bytes()==4096); // refused growth preserves the live buffer
|
||||
uint64_t cursor=0;char chunk[1025];bool lost=false;
|
||||
assert(t.readOutput(cursor,chunk,sizeof(chunk),lost)>0 && lost);
|
||||
}
|
||||
assert(allocated.empty());
|
||||
}
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
folder = Path(temp)
|
||||
for name, text in headers.items():
|
||||
path = folder / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="ascii")
|
||||
binary = folder / "stream"
|
||||
subprocess.run([
|
||||
"c++", "-std=c++17", "-x", "c++", "-", "-I" + str(folder),
|
||||
"-I" + str(ROOT / "src"), "-fsanitize=address,undefined",
|
||||
"-fno-pie", "-no-pie", "-o", str(binary),
|
||||
], input=code, text=True, check=True, capture_output=True)
|
||||
subprocess.run([str(binary)], check=True, capture_output=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -191,6 +191,65 @@ class WebConfigUiRuntimeTest(unittest.TestCase):
|
||||
"set bridge.enabled ", "get mqtt1.password", "set mqtt2.preset "):
|
||||
self.assertNotIn(command, suggestions)
|
||||
|
||||
def test_stream_terminal_imports_long_cards_pages_lists_and_receives_late_replies(self):
|
||||
status, config = self.setup_values()
|
||||
status.update(mode="lan", cli=True, terminal_stream=True,
|
||||
terminal_max_command=541, password_supported=False)
|
||||
prelude = """
|
||||
<script>
|
||||
(function(){
|
||||
var status=%s,config=%s,commands=[],sequence=0,output="",session="";
|
||||
function response(value){return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(value)}})}
|
||||
window.fetch=function(path,options){
|
||||
if(path==="/api/status")return response(status);
|
||||
if(path==="/api/config")return response(config);
|
||||
if(path==="/api/presets")return response({presets:[]});
|
||||
if(path==="/api/terminal"){
|
||||
var p=JSON.parse(options.body);commands.push(p.command);sequence=p.seq;session=p.session;
|
||||
if(p.command.indexOf("import ")===0){output+="OK - contact import queued\\n";return Promise.reject(new Error("lost POST response"))}
|
||||
if(p.command==="list")for(var i=0;i<350;i++)output+="contact-"+i+" (Repeater)\\n";
|
||||
if(p.command.indexOf("to ")===0)output+="Selected recipient test\\n";
|
||||
if(p.command==="help")output+="firmware help: import, list, to, send\\n";
|
||||
return response({accepted:true});
|
||||
}
|
||||
if(path.indexOf("/api/terminal?")===0){
|
||||
var q=new URLSearchParams(path.split("?")[1]),from=Number(q.get("after")),text=output.slice(from,from+1024);
|
||||
return response({output:text,cursor:from+text.length,seq:sequence,done:true,closed:false,lost:false,more:from+text.length<output.length});
|
||||
}
|
||||
if(path.indexOf("/api/cli")===0)throw new Error("wrong CLI parser");
|
||||
return response({});
|
||||
};
|
||||
window.addEventListener("load",function(){
|
||||
setTimeout(function(){
|
||||
document.querySelector('#tabs button[data-t="cli"]').click();
|
||||
cliStreamRun(["import meshcore://"+"ab".repeat(255),"list","to test"]);
|
||||
},75);
|
||||
setTimeout(function(){output+="Late RF reply and ACK\\n"},300);
|
||||
setTimeout(function(){cliHelp()},550);
|
||||
setTimeout(function(){
|
||||
document.body.setAttribute("data-test-commands",JSON.stringify(commands));
|
||||
document.body.setAttribute("data-test-stream-output",document.getElementById("term-out").textContent);
|
||||
document.body.setAttribute("data-test-stream-busy",String(cli.busy));
|
||||
document.body.setAttribute("data-test-stream-session",session);
|
||||
},1000);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
""" % (json.dumps(status), json.dumps(config))
|
||||
dom = self.run_page(prelude, virtual_time=1200)
|
||||
import html
|
||||
commands = json.loads(html.unescape(re.search(
|
||||
r'data-test-commands="([^"]*)"', dom).group(1)))
|
||||
self.assertEqual(commands, ["import meshcore://" + "ab" * 255,
|
||||
"list", "to test", "help"])
|
||||
output = html.unescape(re.search(
|
||||
r'data-test-stream-output="([^"]*)"', dom).group(1))
|
||||
self.assertIn("contact-0 (Repeater)", output)
|
||||
self.assertIn("contact-349 (Repeater)", output)
|
||||
self.assertEqual(output.count("Late RF reply and ACK"), 1)
|
||||
self.assertIn("firmware help: import, list, to, send", output)
|
||||
self.assertIn('data-test-stream-busy="false"', dom)
|
||||
|
||||
def test_console_stays_hidden_when_disabled_or_on_setup_ap(self):
|
||||
for mode in ("lan", "setup"):
|
||||
with self.subTest(mode=mode):
|
||||
|
||||
+113
-3
@@ -561,6 +561,8 @@ body.tab-cli{padding-bottom:0}
|
||||
<button type="button" class="hide" id="term-setup" onmousedown="event.preventDefault()"
|
||||
onclick="showWizard()">← setup</button>
|
||||
<button type="button" onmousedown="event.preventDefault()" onclick="cliHelp()">help</button>
|
||||
<button type="button" class="hide" id="term-disconnect" onmousedown="event.preventDefault()"
|
||||
onclick="if(!cli.busy)cliRun(['disconnect'])">disconnect</button>
|
||||
<button type="button" onmousedown="event.preventDefault()" onclick="cliClear()">clear</button>
|
||||
</div>
|
||||
<div class="term-out" id="term-out"></div>
|
||||
@@ -737,6 +739,8 @@ function boot(){
|
||||
// (active_slots: 2 without PSRAM, 5 with). Fall back to the runtime array
|
||||
// size for older firmware that doesn't report it.
|
||||
st.mode=s.mode;st.authed=s.auth;st.hasMqtt=!!s.mqtt;st.hasCli=!!s.cli;
|
||||
st.streamTerminal=!!s.terminal_stream;st.terminalMax=s.terminal_max_command||541;
|
||||
$("#term-disconnect").classList.toggle("hide",!st.streamTerminal);
|
||||
st.wifiPsk64=!!s.wifi_psk64;
|
||||
st.passwordSupported=!!s.password_supported;st.needsPassword=!!s.needs_password;
|
||||
st.radioOptional=!!s.radio_optional;
|
||||
@@ -1954,9 +1958,28 @@ var CLI_SLOT=[
|
||||
];
|
||||
var CLI_TYPES="req,response,txt_msg,ack,advert,grp_txt,grp_data,anon_req,path,trace,multipart,control,raw_custom".split(",");
|
||||
|
||||
// Companion has its own command parser. Offer its settings, not the repeater's
|
||||
// ACL, bridge and filesystem commands or stateful USB/TCP chat commands.
|
||||
// Companion uses its USB/TCP command parser, including its chat session.
|
||||
// Repeater ACL and rescue-only filesystem commands belong to other parsers.
|
||||
var CLI_COMPANION_VERBS=[
|
||||
["help","List commands supported by this firmware"],
|
||||
["card","Export this node's meshcore:// contact card"],
|
||||
["import ","Add a contact: import meshcore:// followed by the full card"],
|
||||
["list","List contacts"], ["list ","List the first N contacts"],
|
||||
["to","Show selected recipient"], ["to ","Select a contact by name or name prefix"],
|
||||
["path","Show the selected contact's path"], ["path ","Set path: direct, clear, or hop prefixes"],
|
||||
["reset path","Clear the selected contact's path"],
|
||||
["send ","Send a direct message to the selected contact"],
|
||||
["login ","Log into the selected repeater or room with its admin password"],
|
||||
["cmd ","Run a remote command on the selected contact"],
|
||||
["trace","Trace the selected contact"], ["trace ","Trace a contact or raw path"],
|
||||
["channels","List channels"], ["channel ","Send: channel <name-or-slot> <text>"],
|
||||
["public ","Send a message to channel 0"],
|
||||
["show","Show terminal receive filters"], ["show ","Set adverts, channels, or emergency output on/off"],
|
||||
["clock","Show node time"], ["time ","Set time in epoch seconds"],
|
||||
["advert","Send an advertisement"],
|
||||
["start ota","Start WiFi firmware update (Full Companion: port 8080)"],
|
||||
["stop ota","Stop WiFi firmware update"],
|
||||
["disconnect","Close the browser or TCP terminal session"],
|
||||
["ver","Firmware version"],
|
||||
["board","Board and hardware info"],
|
||||
["memory","Heap and PSRAM free/min/largest block (Full Companion)"],
|
||||
@@ -2069,7 +2092,7 @@ function cliEcho(cls,text){
|
||||
return d;
|
||||
}
|
||||
function cliGap(){var d=cliEcho("gap","");return d}
|
||||
function cliClear(){$("#term-out").innerHTML="";cliBanner();$("#term-in").focus()}
|
||||
function cliClear(){$("#term-out").innerHTML="";cliStream.out=null;cliBanner();$("#term-in").focus()}
|
||||
function cliBanner(){
|
||||
cliEcho("sys","MeshCore "+shortVer());
|
||||
cliEcho("sys",(st.role||"")+" * "+(st.board||"")+
|
||||
@@ -2084,6 +2107,7 @@ function cliBanner(){
|
||||
cliEcho("sys","Type help for a summary, or a prefix and Tab to complete.");
|
||||
}
|
||||
function cliHelp(){
|
||||
if(st.streamTerminal){if(!cli.busy)cliRun(["help"]);return}
|
||||
cliGap();
|
||||
cliEcho("cmd","help");
|
||||
cliEcho("rep",
|
||||
@@ -2393,6 +2417,7 @@ function cliBusy(on){
|
||||
if(!on&&window.matchMedia("(pointer:fine)").matches)$("#term-in").focus();
|
||||
}
|
||||
function cliRun(cmds){
|
||||
if(st.streamTerminal){cliStreamRun(cmds);return}
|
||||
cliBusy(true);
|
||||
cliGap();
|
||||
var reqid=mkReqId();
|
||||
@@ -2411,6 +2436,91 @@ function cliRun(cmds){
|
||||
cliPoll(reqid,cmds,0,status,0,0);
|
||||
});
|
||||
}
|
||||
|
||||
// A real Companion terminal: one session owns recipient/login state, and
|
||||
// cursor reads continue after a command finishes to show ACKs and RF replies.
|
||||
var cliStream={session:"",seq:0,cursor:0,timer:0,polling:false,out:null,job:null,errors:0};
|
||||
function cliStreamReset(){
|
||||
clearTimeout(cliStream.timer);
|
||||
cliStream.session="";cliStream.seq=0;cliStream.cursor=0;
|
||||
cliStream.job=null;cliStream.out=null;cliStream.errors=0;
|
||||
cliBusy(false);
|
||||
}
|
||||
function cliStreamText(text){
|
||||
if(!text)return;
|
||||
var out=$("#term-out"),atEnd=out.scrollTop+out.clientHeight>=out.scrollHeight-24;
|
||||
if(!cliStream.out||!cliStream.out.parentNode)cliStream.out=cliEcho("rep","");
|
||||
cliStream.out.textContent=(cliStream.out.textContent+text.replace(/\r/g,"")).slice(-65536);
|
||||
if(atEnd)out.scrollTop=out.scrollHeight;
|
||||
}
|
||||
function cliStreamRun(cmds){
|
||||
if(cli.busy)return;
|
||||
if(cmds.some(function(c){return new TextEncoder().encode(c).length>st.terminalMax})){
|
||||
cliEcho("err","Command exceeds "+st.terminalMax+" bytes; nothing was sent.");return;
|
||||
}
|
||||
if(!cliStream.session)cliStream.session=mkReqId();
|
||||
cliBusy(true);
|
||||
cliStream.job={cmds:cmds.slice(),index:0,seq:0,started:Date.now()};
|
||||
cliStreamSend();
|
||||
}
|
||||
function cliStreamSend(){
|
||||
var job=cliStream.job,session=cliStream.session;
|
||||
var command=job.cmds[job.index];
|
||||
job.seq=++cliStream.seq;job.started=Date.now();
|
||||
cliEcho("cmd",/^login\s/i.test(command)?"login ********":command);
|
||||
cliStream.out=null;
|
||||
post("/api/terminal",{session:session,seq:job.seq,command:command}).then(function(){
|
||||
if(cliStream.session===session)cliStreamPoll();
|
||||
}).catch(function(e){
|
||||
if(cliStream.session!==session)return;
|
||||
if(e.status>=400&&e.status<500||e.status===503){
|
||||
cliEcho("err",e.message||"Command rejected.");
|
||||
if(e.status===400){cliStream.seq--;cliStream.job=null;cliBusy(false)}
|
||||
else cliStreamReset();
|
||||
return;
|
||||
}
|
||||
// The POST may have arrived. Poll its sequence; never resend a chat message.
|
||||
cliStreamPoll();
|
||||
});
|
||||
}
|
||||
function cliStreamPoll(){
|
||||
clearTimeout(cliStream.timer);
|
||||
if(!cliStream.session||cliStream.polling)return;
|
||||
var session=cliStream.session;
|
||||
cliStream.polling=true;
|
||||
api("/api/terminal?session="+encodeURIComponent(session)+"&after="+cliStream.cursor,{timeout:5000})
|
||||
.then(function(r){
|
||||
cliStream.polling=false;
|
||||
if(cliStream.session!==session){cliStreamPoll();return}
|
||||
cliStream.errors=0;
|
||||
if(r.lost)cliEcho("err","Some terminal output expired while disconnected.");
|
||||
cliStreamText(r.output||"");cliStream.cursor=r.cursor;
|
||||
if(r.more){cliStream.timer=setTimeout(cliStreamPoll,0);return}
|
||||
var job=cliStream.job;
|
||||
if(r.closed){cliStreamReset();return}
|
||||
if(job&&r.seq===job.seq&&r.done){
|
||||
cliAfter([job.cmds[job.index]],{});
|
||||
if(++job.index<job.cmds.length){cliStreamSend();return}
|
||||
cliStream.job=null;cliBusy(false);
|
||||
}else if(job&&Date.now()-job.started>30000){
|
||||
cliEcho("err","Command completion could not be confirmed. Check the node before trying again.");
|
||||
cliStreamReset();return;
|
||||
}
|
||||
cliStream.timer=setTimeout(cliStreamPoll,cliStream.job?100:400);
|
||||
}).catch(function(e){
|
||||
cliStream.polling=false;
|
||||
if(cliStream.session!==session){cliStreamPoll();return}
|
||||
if(e.status===401||e.status===403||e.status===404||++cliStream.errors>20){
|
||||
cliEcho("err",e.message||"Terminal disconnected.");cliStreamReset();return;
|
||||
}
|
||||
cliStream.timer=setTimeout(cliStreamPoll,700);
|
||||
});
|
||||
}
|
||||
window.addEventListener("pagehide",function(){
|
||||
if(!cliStream.session)return;
|
||||
fetch("/api/terminal",{method:"POST",keepalive:true,headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify({session:cliStream.session,seq:cliStream.seq+1,command:"disconnect"})}).catch(function(){});
|
||||
});
|
||||
function cliEnd(status,msg){
|
||||
cliBusy(false);
|
||||
status.className="err";status.textContent=msg;
|
||||
|
||||
Reference in New Issue
Block a user