Share the Companion terminal with the WiFi console

This commit is contained in:
mikecarper committed 2026-09-08 19:48:15 -07:00
1 parent cb79dcd4e3
commit d178cf1474
20 files changed
+3047 -2062

No files matched your search

+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",