Public Access
The framework's server begin() fails without a word: the console's task now asks whether it listens, says so, and tries again. `debug off <seconds>` closes the console and reopens it after the pause, which is the only way to test its closing and reopening from afar. Checked on the device: 25 closings and reopenings, each back a second after the pause. Free heap dips about 270 bytes for each connection the device closes and is all back two minutes later (TCP keeps a closed connection that long): not a leak. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
394 lines
16 KiB
C++
394 lines
16 KiB
C++
|
|
#include "services/debug_console.h"
|
|
|
|
#include <WiFi.h>
|
|
|
|
#include <algorithm>
|
|
#include <esp_core_dump.h>
|
|
#include <esp_flash.h>
|
|
#include <esp_random.h>
|
|
|
|
#include <SD.h>
|
|
#include <unistd.h>
|
|
|
|
#include <atomic>
|
|
#include <memory>
|
|
|
|
#include "file_receiver.h"
|
|
#include "sha256.h"
|
|
#include "platform/console.h"
|
|
#include "platform/counted_client.h"
|
|
#include "sd_fault.h"
|
|
#include "version.h"
|
|
|
|
namespace roro {
|
|
|
|
namespace {
|
|
|
|
constexpr uint32_t kAuthTimeoutMs = 10000;
|
|
constexpr size_t kMaxLine = 240;
|
|
constexpr size_t kMaxQueued = 8;
|
|
|
|
// Reads one line (without its \r\n) within `timeoutMs`; false on a timeout, a drop or an overlong line.
|
|
bool readLine(NetworkClient& c, std::string& line, uint32_t timeoutMs) {
|
|
line.clear();
|
|
uint32_t since = millis();
|
|
while (c.connected() && millis() - since < timeoutMs) {
|
|
int ch = c.read();
|
|
if (ch < 0) {
|
|
delay(10);
|
|
continue;
|
|
}
|
|
if (ch == '\n') {
|
|
if (!line.empty() && line.back() == '\r') line.pop_back();
|
|
return true;
|
|
}
|
|
if (line.size() >= kMaxLine) return false;
|
|
line += static_cast<char>(ch);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// The raw core dump partition contents, as esp-coredump reads them ("-t raw").
|
|
void sendCoreDump(NetworkClient& client) {
|
|
size_t addr = 0, size = 0;
|
|
if (esp_core_dump_image_check() != ESP_OK || esp_core_dump_image_get(&addr, &size) != ESP_OK) {
|
|
client.print("coredump: none\n");
|
|
return;
|
|
}
|
|
client.printf("coredump: data %u\n", (unsigned)size);
|
|
uint8_t buf[1024];
|
|
for (size_t off = 0; off < size;) {
|
|
size_t n = std::min(sizeof buf, size - off);
|
|
if (esp_flash_read(nullptr, buf, addr + off, n) != ESP_OK) break; // the host sees a short file
|
|
if (client.write(buf, n) != n) return;
|
|
off += n;
|
|
}
|
|
client.print("coredump: end\n");
|
|
}
|
|
|
|
} // namespace
|
|
|
|
DebugConsole::DebugConsole(WifiService& wifi, StorageService& storage, Settings& settings)
|
|
: wifi_(wifi), storage_(storage), settings_(settings), lock_(xSemaphoreCreateMutex()) {}
|
|
|
|
std::string DebugConsole::freshToken() {
|
|
uint8_t random[debug::kTokenRandom];
|
|
esp_fill_random(random, sizeof random); // the hardware generator: true random with the radio on
|
|
return debug::makeToken(random);
|
|
}
|
|
|
|
void DebugConsole::switchOn(Settings& settings) {
|
|
if (settings.getString(Setting::DebugToken).empty()) settings.setString(Setting::DebugToken, freshToken());
|
|
settings.setBool(Setting::DebugConsole, true);
|
|
}
|
|
|
|
void DebugConsole::tick(uint32_t) { apply(); }
|
|
|
|
// The main loop's side. The task frees what it holds and clears task_ when it sees wanted_ go:
|
|
// until then a new one isn't started, so switching off and on again quickly takes a tick or two.
|
|
void DebugConsole::apply() {
|
|
const std::string& token = settings_.getString(Setting::DebugToken);
|
|
bool want = settings_.getBool(Setting::DebugConsole) && !token.empty(); // no token, nobody could get in: stay closed
|
|
xSemaphoreTake(lock_, portMAX_DELAY);
|
|
if (token != token_) {
|
|
token_ = token;
|
|
tokenSeq_ = tokenSeq_ + 1;
|
|
}
|
|
xSemaphoreGive(lock_);
|
|
wanted_ = want;
|
|
if (!want || task_) return;
|
|
if (!console.openRing()) return (void)console.println("debug: no memory for the console");
|
|
console.captureEspLogs();
|
|
TaskHandle_t made = nullptr;
|
|
if (xTaskCreate(taskEntry, "debug", 6144, this, 1, &made) == pdPASS) task_ = made;
|
|
else console.closeRing();
|
|
}
|
|
|
|
bool DebugConsole::takeCommand(std::string& line) {
|
|
xSemaphoreTake(lock_, portMAX_DELAY);
|
|
bool any = !commands_.empty();
|
|
if (any) {
|
|
line = std::move(commands_.front());
|
|
commands_.pop_front();
|
|
}
|
|
xSemaphoreGive(lock_);
|
|
return any;
|
|
}
|
|
|
|
bool DebugConsole::takeAlert(std::string& text) {
|
|
xSemaphoreTake(lock_, portMAX_DELAY);
|
|
bool any = !alert_.empty();
|
|
if (any) text = std::move(alert_);
|
|
alert_.clear();
|
|
xSemaphoreGive(lock_);
|
|
return any;
|
|
}
|
|
|
|
void DebugConsole::taskEntry(void* self) { static_cast<DebugConsole*>(self)->listen(); }
|
|
|
|
void DebugConsole::listen() {
|
|
{
|
|
NetworkServer server(kPort);
|
|
bool listening = false, complained = false;
|
|
while (wanted_) {
|
|
bool up = wifi_.state() == WifiController::State::Connected;
|
|
if (up && !listening) {
|
|
// begin() gives up without a word (no socket, the port taken): ask whether it
|
|
// listens, and try again rather than believe it.
|
|
server.begin();
|
|
listening = static_cast<bool>(server);
|
|
if (!listening) {
|
|
if (!complained) console.printf("debug: can't listen on port %u (errno %d), trying again\n", kPort, errno);
|
|
complained = true;
|
|
server.end(); // closes the socket a failed begin() leaves open
|
|
vTaskDelay(pdMS_TO_TICKS(2000));
|
|
} else if (complained) {
|
|
console.printf("debug: listening on port %u\n", kPort);
|
|
complained = false;
|
|
}
|
|
} else if (!up && listening) {
|
|
server.end();
|
|
listening = false;
|
|
}
|
|
if (listening) {
|
|
Counted<NetworkClient> client(server.accept(), net::User::DebugConsole);
|
|
if (client) {
|
|
client.setNoDelay(true);
|
|
if (authenticate(client)) serve(client);
|
|
client.stop();
|
|
}
|
|
}
|
|
vTaskDelay(pdMS_TO_TICKS(200));
|
|
}
|
|
if (listening) server.end();
|
|
}
|
|
// Switched off: nothing is left behind (Q189). The commands a client queued die with it. (Free
|
|
// heap dips by about 270 bytes for each connection the device closed, for two minutes: TCP keeps
|
|
// them that long. Measured over 15 switches: all of it comes back.)
|
|
xSemaphoreTake(lock_, portMAX_DELAY);
|
|
commands_.clear();
|
|
xSemaphoreGive(lock_);
|
|
console.closeRing();
|
|
task_ = nullptr;
|
|
vTaskDelete(nullptr);
|
|
}
|
|
|
|
// The token never crosses the network (Q193): the device sends 16 random bytes, the client sends
|
|
// back their HMAC-SHA256 keyed by the token. A recorded answer is no use for the next challenge.
|
|
bool DebugConsole::authenticate(NetworkClient& client) {
|
|
if (gate_.locked(millis())) {
|
|
client.print("locked\n");
|
|
return false;
|
|
}
|
|
xSemaphoreTake(lock_, portMAX_DELAY);
|
|
std::string token = token_;
|
|
xSemaphoreGive(lock_);
|
|
uint8_t nonce[debug::kNonceBytes];
|
|
esp_fill_random(nonce, sizeof nonce);
|
|
client.printf("%s debug console, challenge %s\n", kProductName, debug::toHex(nonce, sizeof nonce).c_str());
|
|
|
|
std::string answer;
|
|
bool answered = readLine(client, answer, kAuthTimeoutMs);
|
|
if (answered && !token.empty() && debug::sameText(answer, debug::answerFor(token, nonce))) {
|
|
gate_.succeeded();
|
|
return true;
|
|
}
|
|
std::string from = client.remoteIP().toString().c_str();
|
|
console.printf("debug: refused a client from %s\n", from.c_str());
|
|
delay(1000); // no quick retries
|
|
client.print("denied\n");
|
|
if (answered && gate_.failed(millis())) { // a connection that says nothing isn't a guess
|
|
console.printf("debug: %d wrong tokens in a row, closed for %lu s\n", debug::AuthGate::kMaxFailures,
|
|
(unsigned long)(debug::AuthGate::kLockMs / 1000));
|
|
xSemaphoreTake(lock_, portMAX_DELAY);
|
|
alert_ = "Console closed, wrong tokens: " + from; // an Event's text holds 47 characters
|
|
xSemaphoreGive(lock_);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void DebugConsole::serve(NetworkClient& client) {
|
|
console.printf("debug: client %s connected\n", client.remoteIP().toString().c_str());
|
|
client.printf("%s %s debug console. 'help' lists the commands. Backlog follows.\n", kProductName, versionString());
|
|
connected_ = true;
|
|
uint32_t tokenSeq = tokenSeq_;
|
|
uint32_t pos = console.oldest();
|
|
uint8_t buf[512];
|
|
std::string line;
|
|
while (client.connected() && wanted_ && tokenSeq == tokenSeq_) { // switched off, or a new token: out
|
|
// Console output since last time, including the replies to this client's commands.
|
|
uint32_t skipped = 0;
|
|
size_t n;
|
|
while ((n = console.readSince(pos, buf, sizeof buf, skipped)) > 0) {
|
|
if (skipped) client.printf("\n[... %u bytes lost: the console ran faster than the network]\n", (unsigned)skipped);
|
|
if (client.write(buf, n) != n) break;
|
|
}
|
|
// Commands, one line at a time.
|
|
while (client.available()) {
|
|
int ch = client.read();
|
|
if (ch == '\r') continue;
|
|
if (ch != '\n') {
|
|
if (line.size() < kMaxLine) line += static_cast<char>(ch);
|
|
continue;
|
|
}
|
|
if (line == "quit" || line == "exit") {
|
|
client.stop();
|
|
break;
|
|
}
|
|
if (binaryCommand(client, line)) {
|
|
line.clear();
|
|
continue;
|
|
}
|
|
xSemaphoreTake(lock_, portMAX_DELAY);
|
|
bool full = commands_.size() >= kMaxQueued;
|
|
if (!full && !line.empty()) commands_.push_back(line);
|
|
xSemaphoreGive(lock_);
|
|
if (full) client.print("debug: busy, command dropped\n");
|
|
line.clear();
|
|
}
|
|
vTaskDelay(pdMS_TO_TICKS(20));
|
|
}
|
|
connected_ = false;
|
|
console.println("debug: client disconnected");
|
|
}
|
|
|
|
bool DebugConsole::binaryCommand(NetworkClient& client, const std::string& line) {
|
|
if (line == "reset") { // works even when the main loop is stuck
|
|
client.print("debug: restarting now\n");
|
|
client.flush();
|
|
delay(200);
|
|
esp_restart();
|
|
}
|
|
if (line == "coredump get") sendCoreDump(client);
|
|
else if (line.rfind("get ", 0) == 0) get(client, line.substr(4));
|
|
else if (line.rfind("put ", 0) == 0) put(client, line.substr(4));
|
|
else if (line == "screenshot") screenshot(client);
|
|
else return false;
|
|
return true;
|
|
}
|
|
|
|
bool DebugConsole::onStorage(std::function<void()> job) { return storage_.runAndWait(std::move(job)); }
|
|
|
|
void DebugConsole::get(NetworkClient& client, const std::string& path) {
|
|
bool ran = onStorage([&]() {
|
|
File f = SD.open(path.c_str());
|
|
if (!f || f.isDirectory()) return (void)client.printf("get: error cannot open %s\n", path.c_str());
|
|
size_t size = f.size();
|
|
client.printf("get: data %u\n", (unsigned)size);
|
|
uint8_t buf[1024];
|
|
for (size_t sent = 0; sent < size;) {
|
|
int n = f.read(buf, std::min(sizeof buf, size - sent));
|
|
if (n <= 0 || client.write(buf, n) != static_cast<size_t>(n)) break; // the host sees it short
|
|
sent += n;
|
|
}
|
|
f.close();
|
|
client.print("get: end\n");
|
|
});
|
|
if (!ran) client.print("get: error no SD card\n");
|
|
}
|
|
|
|
// The same checks as `sd put` over serial (FileReceiver), but TCP does the flow control, so the
|
|
// file is written in one job with the file kept open.
|
|
void DebugConsole::put(NetworkClient& client, const std::string& args) {
|
|
FileReceiver r;
|
|
std::string error = r.begin(args, millis());
|
|
StorageState card = storage_.state();
|
|
if (error.empty() && !card.present) error = "no SD card";
|
|
if (error.empty() && card.totalBytes - card.usedBytes < r.size() + 64 * 1024) error = "not enough space";
|
|
if (!error.empty()) return (void)client.printf("put: error %s\n", error.c_str());
|
|
|
|
bool ran = onStorage([&]() {
|
|
std::string part = r.partPath();
|
|
for (size_t slash = part.find('/', 1); slash != std::string::npos; slash = part.find('/', slash + 1)) {
|
|
std::string dir = part.substr(0, slash);
|
|
if (!SD.exists(dir.c_str())) SD.mkdir(dir.c_str());
|
|
}
|
|
File f = SD.open(part.c_str(), FILE_WRITE);
|
|
if (!f) return (void)client.print("put: error card not writable\n");
|
|
client.printf("put: ready %u\n", (unsigned)r.size());
|
|
uint8_t buf[1024];
|
|
using S = FileReceiver::State;
|
|
while (r.state() == S::Receiving || r.state() == S::Writing) {
|
|
if (r.state() == S::Writing) {
|
|
const auto& c = r.chunk();
|
|
uint32_t t0 = millis();
|
|
bool ok = f.write(c.data(), c.size()) == c.size();
|
|
uint32_t took = millis() - t0;
|
|
// Why, from our copy of the SD driver (lib/SD, #21): the step that gave up and what
|
|
// the card answered. How long it took tells a 500 ms busy timeout from a refusal.
|
|
if (!ok) {
|
|
SdFault sd = sdLastFault();
|
|
console.printf("put: write failed at %u after %u ms: SD step %u, answer 0x%02X, status 0x%04X (%u so far)\n",
|
|
(unsigned)r.received(), (unsigned)took, sd.step, sd.token, (unsigned)sd.resp, (unsigned)sd.count);
|
|
}
|
|
// A card can fail one write and take the next. FATFS keeps a failed file in error,
|
|
// so: close, cut back to the last good byte, reopen, try again. Earlier chunks may
|
|
// have been lost with the write buffer (M3: 3 KB came back as zeros): never extend
|
|
// the file to cover them; give up instead, as the sender can't resend them.
|
|
for (int retry = 1; !ok && retry <= 3; retry++) {
|
|
console.printf("put: write failed at %u, retry %d\n", (unsigned)r.received(), retry);
|
|
f.close();
|
|
delay(50 * retry);
|
|
f = SD.open(part.c_str(), FILE_READ);
|
|
size_t onCard = f ? f.size() : 0;
|
|
if (f) f.close();
|
|
if (onCard < r.received()) {
|
|
console.printf("put: the card lost %u B written before\n", (unsigned)(r.received() - onCard));
|
|
break;
|
|
}
|
|
truncate(("/sd" + part).c_str(), r.received());
|
|
f = SD.open(part.c_str(), FILE_APPEND);
|
|
ok = f && f.size() == r.received() && f.write(c.data(), c.size()) == c.size();
|
|
}
|
|
r.chunkWritten(ok, millis());
|
|
continue;
|
|
}
|
|
int n = client.read(buf, std::min(sizeof buf, r.wanted()));
|
|
if (n > 0) r.feed(buf, n, millis());
|
|
else if (!client.connected()) break;
|
|
else {
|
|
delay(2);
|
|
r.tick(millis());
|
|
}
|
|
}
|
|
f.close();
|
|
if (r.state() == S::Finishing) { // read it back: the received checksum doesn't cover the card
|
|
Sha256 sha;
|
|
f = SD.open(part.c_str(), FILE_READ);
|
|
for (int n; f && (n = f.read(buf, sizeof buf)) > 0;) sha.update(buf, n);
|
|
if (f) f.close();
|
|
uint8_t digest[32];
|
|
sha.finish(digest);
|
|
r.cardChecked(digest);
|
|
}
|
|
if (r.state() == S::Finishing) {
|
|
if (SD.exists(r.path().c_str())) SD.remove(r.path().c_str());
|
|
r.finished(SD.rename(part.c_str(), r.path().c_str()));
|
|
}
|
|
if (r.state() == S::Done) client.printf("put: done %s %u B\n", r.path().c_str(), (unsigned)r.size());
|
|
else {
|
|
SD.remove(part.c_str());
|
|
client.printf("put: error %s\n", r.error().empty() ? "connection lost" : r.error().c_str());
|
|
// The rest of the file is still on its way: never read it as commands.
|
|
client.flush();
|
|
client.stop();
|
|
}
|
|
});
|
|
if (!ran) client.print("put: error no SD card\n");
|
|
}
|
|
|
|
// The frame as composed off-screen (RGB332, one byte a pixel). Read while the UI may be drawing,
|
|
// so it can tear; it's for looking, not for pixel-exact tests.
|
|
void DebugConsole::screenshot(NetworkClient& client) {
|
|
const uint8_t* pixels = frame_ ? static_cast<const uint8_t*>(frame_->getBuffer()) : nullptr;
|
|
if (!pixels) return (void)client.print("screenshot: error no frame\n");
|
|
int w = frame_->width(), h = frame_->height();
|
|
client.printf("screenshot: rgb332 %d %d\n", w, h);
|
|
for (int y = 0; y < h; y += 16) client.write(pixels + y * w, w * std::min(16, h - y));
|
|
client.print("screenshot: end\n");
|
|
}
|
|
|
|
} // namespace roro
|
|
|