Share the Companion terminal with the WiFi console

This commit is contained in:
mikecarper
2026-09-08 19:48:15 -07:00
parent cb79dcd4e3
commit d178cf1474
20 changed files with 3047 additions and 2062 deletions
+15 -14
View File
@@ -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
View File
@@ -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.
+6 -3
View File
@@ -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
+4 -1
View File
@@ -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.
+4 -2
View File
@@ -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?
+36
View File
@@ -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
+7 -7
View File
@@ -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`,
+13 -1
View File
@@ -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
+43 -8
View File
@@ -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;
+7
View File
@@ -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:
+100
View File
@@ -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
File diff suppressed because it is too large Load Diff
+177
View File
@@ -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",
+15
View File
@@ -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);
+82
View File
@@ -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 {}
};
+10
View File
@@ -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();
}
+108
View File
@@ -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()
+59
View File
@@ -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
View File
@@ -561,6 +561,8 @@ body.tab-cli{padding-bottom:0}
<button type="button" class="hide" id="term-setup" onmousedown="event.preventDefault()"
onclick="showWizard()">&larr; 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;