One firmware: the Debug Console in every build, off until switched on, with the device's own token
CI / build (pull_request) Successful in 7m20s
Site / build (pull_request) Successful in 9s

There is no Debug Build any more (ADR 0010, issue #68, Q188 to Q195). The
console and the test commands are compiled into every firmware. It listens
only while Settings > Debug Console is on, which isn't the default; off,
neither its task nor its 4 KB ring exists. The token is made by the device
and shown on that page; a client proves it knows it by answering a challenge
with an HMAC, so it never crosses the network, and five wrong answers close
the console for a minute. DBG in the Status Bar while it listens.

Over USB serial only: debug on, debug token <value>, debug token new.
scripts/flash.sh --debug uses them to set a device up with the developer's
token. scripts/rdbg.py takes the token from -t, $RORO_DEBUG_TOKEN or the
file, answers the challenge, and fetches a release's ELF to decode a crash.

Gone: the cardputer-adv-debug environment, RORO_DEBUG, the +debug version,
scripts/debug_flags.py, update install ... force, and the rule that a Debug
Build doesn't install releases. Old clients and old firmwares don't talk to
each other.

Against the builds it replaces: 30 KB more flash and 88 bytes more static
RAM than the release, 4 KB less RAM than the Debug Build. 468 host tests.
Checked on the device: off by default, login, the pause after wrong tokens,
Safe Mode with the console, the setting surviving an update, debug off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-06 22:59:35 +02:00
co-authored by Claude Opus 5.5
parent 1353e6a5f9
commit c68741cc46
65 changed files with 1245 additions and 374 deletions
+40 -57
View File
@@ -74,17 +74,13 @@ static ClockService* clockService;
static GnssService* gnssService;
static RadioService* radioService;
static LoraCaptureService* loraCapture;
#ifdef RORO_DEBUG
static NoiseTest* noiseTest;
#endif
static GeminiService* geminiService;
static SavedNetworks* savedNetworks;
static WifiService* wifi;
static IrcService* irc;
static UpdateService* update;
#ifdef RORO_DEBUG
static DebugConsole* debugConsole;
#endif
static Notifier* notifier;
static LauncherApp launcher;
static AppManager* apps;
@@ -131,6 +127,7 @@ static StatusInfo currentStatus() {
s.radio = last && millis() - last < 400 ? StatusInfo::Radio::Packet : StatusInfo::Radio::Listening;
}
s.capturing = loraCapture && loraCapture->capturing();
s.debug = !debugConsole->on() ? StatusInfo::Debug::Off : debugConsole->clientConnected() ? StatusInfo::Debug::Client : StatusInfo::Debug::On;
using WifiState = WifiController::State;
switch (wifi->state()) {
case WifiState::Connected: {
@@ -194,18 +191,14 @@ void setup() {
services.add(*gnssService);
services.add(*radioService);
loraCapture = new LoraCaptureService(*radioService, *storageService, *clockService, bus);
#ifdef RORO_DEBUG
noiseTest = new NoiseTest(*radioService);
#endif
services.add(*loraCapture);
services.add(*storageService);
services.add(*wifi);
services.add(*irc);
services.add(*update);
#ifdef RORO_DEBUG
debugConsole = new DebugConsole(*wifi, *storageService);
debugConsole = new DebugConsole(*wifi, *storageService, settings);
services.add(*debugConsole);
#endif
apps = new AppManager(launcher);
launcher.setManager(*apps);
@@ -228,9 +221,7 @@ void setup() {
bus.subscribe(EventType::Notification, [](const Event& e) { console.printf("notification: %s\n", e.text); });
if (!screen.begin()) console.println("frame buffer allocation failed");
#ifdef RORO_DEBUG
debugConsole->setFrame(screen.canvas()); // for `screenshot`
#endif
services.startAll(millis());
apps->begin();
if (!settings.getBool(Setting::SetupDone)) apps->openModal("setup");
@@ -246,7 +237,7 @@ void setup() {
enableLoopWDT();
}
// Safe Mode: Wi-Fi, the clock, Firmware Updates and (in a Debug Build) the Debug Console. No Apps,
// Safe Mode: Wi-Fi, the clock, Firmware Updates and (if it's switched on) the Debug Console. No Apps,
// no IRC, no SD card: whatever crashed three times in a row is most likely among them.
static void setupSafeMode(int crashes) {
clockService = new ClockService(settings, bus);
@@ -258,15 +249,11 @@ static void setupSafeMode(int crashes) {
services.add(*clockService);
services.add(*wifi);
services.add(*update);
#ifdef RORO_DEBUG
debugConsole = new DebugConsole(*wifi, *storageService);
debugConsole = new DebugConsole(*wifi, *storageService, settings);
services.add(*debugConsole);
#endif
bus.subscribe(EventType::Notification, [](const Event& e) { console.printf("notification: %s\n", e.text); });
screen.begin();
#ifdef RORO_DEBUG
debugConsole->setFrame(screen.canvas());
#endif
services.startAll(millis());
console.printf("%s %s in SAFE MODE: %d crash restarts in a row. Only Wi-Fi and Firmware Updates run.\n",
kProductName, versionString(), crashes);
@@ -385,7 +372,6 @@ static void uploadStep() {
}
}
#ifdef RORO_DEBUG
// `wifi ip ... try <seconds>`: a trial IP setting that reverts unless `wifi ip keep` arrives, so a
// wrong address tried over Wi-Fi doesn't cut the device off for good.
static struct {
@@ -404,7 +390,6 @@ static void ipTrialStep() {
ipTrial.wasFixed ? net::formatFixed(ipTrial.was).c_str() : "Automatic (DHCP)");
wifi->ipSettingChanged(ipTrial.ssid);
}
#endif
// `tasks`: the first sample, and when to take the second and print.
static std::vector<TaskSample> tasksBefore;
@@ -467,13 +452,11 @@ static void printReleases(bool list) {
static void updateStep() {
if (!updateWatch || update->giteaBusy()) return;
#ifdef RORO_DEBUG
if (updateWatch == 3) {
console.printf("update: probe: %s\n", update->probeResult().c_str());
updateWatch = 0;
return;
}
#endif
printReleases(updateWatch == 2);
updateWatch = 0;
}
@@ -492,14 +475,8 @@ static void updateCommand(const String& args) {
printReleases(false);
} else if (args.startsWith("install ")) {
String rest = args.substring(8);
bool force = rest.endsWith(" force");
if (force) rest.remove(rest.length() - 6);
#ifndef RORO_DEBUG
force = false; // only the Debug Console may install on a Debug Build, which a release isn't
#endif
std::string why = update->requestInstall(rest.c_str(), force);
std::string why = update->requestInstall(rest.c_str());
console.printf("update: %s\n", why.empty() ? "installing" : why.c_str());
#ifdef RORO_DEBUG
} else if (args.startsWith("probe ")) { // update probe <host> [path]: is that server's certificate accepted?
String rest = args.substring(6);
int space = rest.indexOf(' ');
@@ -516,7 +493,6 @@ static void updateCommand(const String& args) {
} else if (args.startsWith("pretend ")) { // update pretend v0.9.0 | off: what the comparisons take as running
update->pretendVersion(args.substring(8) == "off" ? "" : args.substring(8).c_str());
console.printf("update: running %s\n", update->runningVersion().c_str());
#endif
} else {
console.println("update: check | list | status | install <tag>");
}
@@ -553,9 +529,7 @@ static void fileCommand(const String& line) {
}
static uint32_t loopPasses = 0; // counted in loop(), for `tasks` (issue #40)
#ifdef RORO_DEBUG
static bool loopSpin = false; // `loop spin on`: no rest between passes, to compare load and radio noise
#endif
static uint32_t tasksPasses = 0;
static void tasksStep() {
@@ -590,7 +564,8 @@ static const char* const kHelp =
"install <path.ota> Update from SD\n"
"update check | list | status | install <tag> the project's releases on Gitea\n"
"sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
#ifdef RORO_DEBUG
"debug status | debug off the Debug Console over Wi-Fi (Settings > Debug Console)\n"
"debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token\n"
"crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n"
"wifi ip ... try <seconds> | wifi ip keep a trial IP setting: back to the previous one unless kept\n"
"loop spin on|off make the main loop spin without resting, to compare load and radio noise\n"
@@ -601,34 +576,58 @@ static const char* const kHelp =
"reset (Debug Console only) restart at once, even if the main loop is stuck\n"
"get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py\n"
"quit close the Debug Console connection\n"
#endif
;
// Commands that only touch what Safe Mode starts.
static bool safeModeCommand(const String& line) {
return line == "help" || line == "info" || line == "tasks" || line == "net" || line == "reboot" || line == "boot other" ||
line.startsWith("log level ") || line.startsWith("crash") || line.startsWith("coredump") ||
line == "wifi status" || line.startsWith("wifi add ");
line == "wifi status" || line.startsWith("wifi add ") || line.startsWith("debug ");
}
static void runCommand(String line) {
// `debug ...`: the Debug Console's switch and token (ADR 0010). Switching it on and setting its token
// are for USB serial only (Q192): whoever holds the cable holds the device anyway, and the console
// can't be used to open itself wider. The token is never printed.
static void debugCommand(const String& args, bool fromSerial) {
if (args == "status") {
console.printf("debug: %s, token %s, client %s\n", settings.getBool(Setting::DebugConsole) ? "on" : "off",
settings.getString(Setting::DebugToken).empty() ? "not set" : "set", debugConsole->clientConnected() ? "connected" : "none");
} else if (args == "off") {
settings.setBool(Setting::DebugConsole, false);
console.println("debug: off");
} else if (!fromSerial) {
console.println("debug: over USB serial only (or Settings > Debug Console)");
} else if (args == "on") {
DebugConsole::switchOn(settings);
console.println("debug: on (the token is in Settings > Debug Console)");
} else if (args == "token new") {
settings.setString(Setting::DebugToken, DebugConsole::freshToken());
console.println("debug: a new token (it is in Settings > Debug Console)");
} else if (args.startsWith("token ")) {
std::string token = debug::tidyToken(args.substring(6).c_str());
bool ok = debug::validToken(token) && settings.setString(Setting::DebugToken, token);
console.println(ok ? "debug: token set" : "debug: a token is 16 to 64 characters");
} else console.println("debug: status | off | on | token <16 to 64 characters> | token new");
}
static void runCommand(String line, bool fromSerial = false) {
line.trim();
if (line.isEmpty()) return;
if (safeMode && !safeModeCommand(line)) return (void)console.println("not available in Safe Mode");
if (line == "help") console.print(kHelp);
if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial);
if (line == "info") {
system_info::printSystem(console);
console.printf("wifi: %s, ip %s, rssi %d | sd: %s, %u write faults\n", wifi->ssid().c_str(), wifi->ip().c_str(),
wifi->rssi(), storageService->state().present ? "present" : "none", (unsigned)sdLastFault().count);
console.printf("update: %s\n", update->onProbation() ? "on probation" : "confirmed");
console.printf("update: %s | debug console: %s\n", update->onProbation() ? "on probation" : "confirmed",
!debugConsole->on() ? "off" : debugConsole->clientConnected() ? "on, a client connected" : "on");
system_info::printSlots(console, nvs);
}
#ifdef RORO_DEBUG
if (line == "loop spin on" || line == "loop spin off") {
loopSpin = line.endsWith("on");
console.printf("loop: %s\n", loopSpin ? "spinning, no rest" : "resting between passes");
}
#endif
if (line == "tasks") { // sampled now, printed a second later by tasksStep(): the loop must run in between
tasksTotal = system_info::sampleTasks(tasksBefore);
tasksDueMs = millis() + 1000;
@@ -670,7 +669,6 @@ static void runCommand(String line) {
});
}
if (line.startsWith("install ")) update->installFromSd(line.substring(8).c_str()); // Update from SD
#ifdef RORO_DEBUG
if (line.startsWith("sd fill ")) { // sd fill <folder> <count>: small files, to test a crowded folder
int space = line.lastIndexOf(' ');
std::string folder = line.substring(8, space).c_str();
@@ -689,11 +687,9 @@ static void runCommand(String line) {
console.printf("sd fill: %d files in %s\n", made, folder.c_str());
});
}
#endif
if (line == "lora probe" && radioService) radioService->probe(console);
if (line == "lora status" && radioService) radioService->printStatus(console);
if ((line == "lora rx on" || line == "lora rx off") && radioService) radioService->setEcho(line.endsWith("on"));
#ifdef RORO_DEBUG
if (line == "lora noise report" && noiseTest) noiseTest->printReport(console);
if (line == "lora noise test" && noiseTest && !noiseTest->running()) {
// One thing changed at a time, each put back before the next (issue #20). Wi-Fi off cuts the
@@ -758,7 +754,6 @@ static void runCommand(String line) {
};
noiseTest->start(std::move(conditions));
}
#endif
if (line.startsWith("lora sweep") && radioService) { // on [from MHz] [to MHz] [step kHz] | off | dump
if (line == "lora sweep dump") radioService->printSweep(console);
else if (line == "lora sweep off") {
@@ -776,7 +771,6 @@ static void runCommand(String line) {
console.printf("lora capture: %s\n", why.empty() ? loraCapture->path().c_str() : why.c_str());
}
if (line == "lora capture stop" && loraCapture) loraCapture->stop();
#ifdef RORO_DEBUG
if (line.startsWith("lora inject ") && radioService) { // <hex> [rssi] [snr]: as if received
String hex = line.substring(12);
int space = hex.indexOf(' ');
@@ -787,7 +781,6 @@ static void runCommand(String line) {
for (size_t i = 0; i + 1 < hex.length() && n < sizeof data; i += 2) data[n++] = strtoul(hex.substring(i, i + 2).c_str(), nullptr, 16);
radioService->inject(data, n, rssi, snr);
}
#endif
if (line.startsWith("lora custom ") && radioService) {
double mhz = 0, bw = 0;
unsigned sf = 0, cr = 0, sync = 0, preamble = 8;
@@ -822,11 +815,9 @@ static void runCommand(String line) {
console.println(gnssService->send(line.substring(10).c_str()) ? "gnss: sent" : "gnss: off");
if (line == "crash") crash_report::print(console, nvs);
if (line == "coredump erase") console.println(crash_report::erase() ? "coredump: erased" : "coredump: nothing to erase");
#ifdef RORO_DEBUG
if (line == "crash abort") abort();
if (line == "crash wdt")
for (;;) {} // the main loop never yields: the task watchdog fires
#endif
if (line == "burst")
for (int i = 1; i <= 5; i++)
bus.publish(Event::withText(EventType::Notification, ("Burst " + String(i)).c_str(), 0));
@@ -933,7 +924,6 @@ static void runCommand(String line) {
}
if (line.startsWith("wifi ip ")) { // wifi ip <ssid> dhcp | <address>/<prefix> [gateway] (the SSID may hold spaces)
std::string rest = line.substring(8).c_str();
#ifdef RORO_DEBUG
if (rest == "keep") { // the trial setting stays
console.println(ipTrial.active ? "wifi ip: kept" : "wifi ip: no trial running");
ipTrial.active = false;
@@ -945,7 +935,6 @@ static void runCommand(String line) {
trialS = strtoul(rest.c_str() + tryAt + 5, nullptr, 10);
rest = rest.substr(0, tryAt);
}
#endif
std::string ssid, why;
net::FixedIp fixed;
bool dhcp = rest.size() > 5 && rest.compare(rest.size() - 5, 5, " dhcp") == 0;
@@ -959,9 +948,7 @@ static void runCommand(String line) {
}
}
const SavedNetwork* before = why.empty() ? savedNetworks->find(ssid) : nullptr;
#ifdef RORO_DEBUG
if (before && trialS) ipTrial = {true, millis() + trialS * 1000, ssid, before->fixed, before->ip};
#endif
if (why.empty()) why = savedNetworks->setIp(ssid, dhcp ? nullptr : &fixed);
if (!why.empty()) return (void)console.printf("wifi ip: %s\n", why.c_str());
console.printf("wifi ip: %s is now %s\n", ssid.c_str(), dhcp ? "Automatic (DHCP)" : net::formatFixed(fixed).c_str());
@@ -1007,18 +994,18 @@ static void serialCommands() {
line += c;
continue;
}
runCommand(line);
runCommand(line, true);
line = "";
}
}
static void remoteCommands() {
#ifdef RORO_DEBUG
for (std::string remote; debugConsole->takeCommand(remote);) {
console.printf("> %s\n", remote.c_str()); // so the transcript reads the same on both ends
runCommand(remote.c_str());
}
#endif
for (std::string alert; debugConsole->takeAlert(alert);)
bus.publish(Event::withText(EventType::Notification, alert.c_str(), static_cast<int32_t>(NotificationLevel::Warning)));
}
// After a minute up, the crash streak is over (SafeMode counts starts that crash in a row).
@@ -1069,9 +1056,7 @@ void loop() {
return;
}
loopPass();
#ifdef RORO_DEBUG
if (loopSpin) return;
#endif
if (upload.active()) return; // a serial file transfer: every byte is read promptly
delay(power->screen() == ScreenState::Off ? kLoopRestScreenOffMs : kLoopRestScreenOnMs);
}
@@ -1088,10 +1073,8 @@ static void loopPass() {
serialCommands();
remoteCommands();
tasksStep();
#ifdef RORO_DEBUG
ipTrialStep();
if (noiseTest) noiseTest->step(now);
#endif
noteStableOnce(now);
updateStep();
fileOpsStep();