Files
roro9stack/src/main.cpp
T
twislaandClaude Opus 5.5 9808013fc0
CI / build (pull_request) Successful in 1m49s
Site / build (pull_request) Successful in 10s
Shell: tls, ntp and netstat (#90)
The rest of the issue's list. tls makes a handshake that checks nothing,
then says the certificate in words: who it is for, who signed it, until
when, and whether this device's roots and the name asked for accept it,
with the reason when they don't. ntp compares a time server's clock with
the device's. netstat lists what listens and what is connected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 02:54:59 +02:00

1518 lines
79 KiB
C++

// roro9stack firmware entry point: wires Services, Apps and the UI loop together.
#include <M5Cardputer.h>
#include <SD.h>
#include <deque>
#include <atomic>
#include <memory>
#include "app_manager.h"
#include "png_rgb332.h"
#include "apps/demo_app.h"
#include "apps/note_editor.h"
#include "apps/shell_app.h"
#include "services/net_tools.h"
#include "services/vpn_service.h"
#include "services/web_share.h"
#include "apps/gemini_app.h"
#include "apps/gnss_app.h"
#include "apps/irc_app.h"
#include "apps/launcher_app.h"
#include "apps/lora_scanner_app.h"
#include "apps/notes_app.h"
#include "apps/settings_app.h"
#include "apps/storage_app.h"
#include "apps/system_app.h"
#include "apps/wifi_tools_app.h"
#include "apps/setup_app.h"
#include "event_bus.h"
#include "file_receiver.h"
#include "ipv4.h"
#include "wg_config.h"
#include "traffic.h"
#include "key_mapper.h"
#include "platform/console.h"
#include "platform/crash_report.h"
#include "platform/nvs_store.h"
#include "platform/system_info.h"
#include "sd_card_id.h"
#include "sd_fault.h"
#include "service_manager.h"
#include "platform/identity.h"
#include "file_names.h"
#include "services/battery_service.h"
#include "services/irc_service.h"
#include "services/clock_service.h"
#include "services/debug_console.h"
#include "services/file_ops.h"
#include "update_check.h"
#include "services/gemini_service.h"
#include "services/gnss_service.h"
#include "services/power_service.h"
#include "services/lora_capture_service.h"
#include "services/noise_test.h"
#include "services/radio_service.h"
#include "services/storage_service.h"
#include "services/update_service.h"
#include "services/wifi_service.h"
#include "safe_mode.h"
#include "settings.h"
#include "storage_paths.h"
#include "ui/notifier.h"
#include "ui/screen.h"
#include "version.h"
using namespace roro;
static EventBus bus;
static NvsStore nvs;
static Settings settings(nvs, bus);
static ServiceManager services;
static KeyMapper keyMapper;
static Screen screen;
// Constructed in setup(), after the hardware and Settings are ready.
static BatteryService* battery;
static StorageService* storageService;
static FileOps* fileOps;
static std::vector<std::string> filesInUse(const std::string& path, bool folder);
static PowerService* power;
static ClockService* clockService;
static GnssService* gnssService;
static RadioService* radioService;
static LoraCaptureService* loraCapture;
static NoiseTest* noiseTest;
static GeminiService* geminiService;
static SavedNetworks* savedNetworks;
static WifiService* wifi;
static IrcService* irc;
static UpdateService* update;
static DebugConsole* debugConsole;
static VpnService* vpnService; // not in Safe Mode
static NetTools netTools; // ping, nslookup and the rest (issue #90)
static Notifier* notifier;
static LauncherApp launcher;
static AppManager* apps;
static bool safeMode = false; // see SafeMode: only what it takes to be fixed over the air
static RawKeys readKeys() {
auto state = M5Cardputer.Keyboard.keysState();
RawKeys raw;
raw.chars.assign(state.word.begin(), state.word.end());
raw.fn = state.fn;
raw.opt = state.opt;
raw.ctrl = state.ctrl;
raw.alt = state.alt;
raw.shift = state.shift;
raw.enter = state.enter;
raw.del = state.del;
raw.tab = state.tab;
return raw;
}
static StatusInfo currentStatus() {
StatusInfo s;
s.title = kProductName;
if (apps->foregroundTitle()) s.title = apps->foregroundTitle();
if (battery->estimator().hasReading()) s.batteryPercent = battery->estimator().percent();
s.clock = clockService->displayTime();
auto storageState = storageService->state();
s.sdPresent = storageState.present;
s.sdLevel = storageState.level;
s.compose = keyMapper.pendingCompose();
s.unread = irc->totalUnread();
if (gnssService && gnssService->on()) {
const auto& g = gnssService->state();
bool fresh = gnssService->receiving(millis());
s.gnss = !fresh || g.fix == gnss::FixType::None ? StatusInfo::Gnss::Searching
: g.fix == gnss::FixType::TwoD ? StatusInfo::Gnss::TwoD
: StatusInfo::Gnss::ThreeD;
s.gnssSatellites = g.satellitesUsed;
s.tracking = gnssService->tracking();
}
if (radioService && radioService->sweeping()) s.radio = StatusInfo::Radio::Sweep;
else if (radioService && radioService->listening()) {
uint32_t last = radioService->lastPacketMs();
s.radio = last && millis() - last < 400 ? StatusInfo::Radio::Packet : StatusInfo::Radio::Listening;
}
s.capturing = loraCapture && loraCapture->capturing();
if (vpnService && vpnService->wanted())
s.vpn = vpnService->state() == VpnService::State::Up ? StatusInfo::Vpn::Up : StatusInfo::Vpn::Trying;
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: {
s.wifi = StatusInfo::Wifi::Connected;
int rssi = wifi->rssi();
s.wifiBars = rssi >= -60 ? 3 : rssi >= -70 ? 2 : rssi >= -80 ? 1 : 0;
break;
}
case WifiState::Monitoring: s.wifi = StatusInfo::Wifi::Monitoring; break;
case WifiState::Off: s.wifi = StatusInfo::Wifi::None; break;
default: s.wifi = StatusInfo::Wifi::Searching; break;
}
return s;
}
// Arduino-ESP32 marks a new image valid before setup() unless this returns true. Probation
// (UpdateService::tick) decides instead, and an unconfirmed image stays PENDING_VERIFY.
extern "C" bool verifyRollbackLater() { return true; } // C linkage, or the weak default wins
// The main loop's stack: Arduino's default is 8 KB; the measured peak is 3 KB (M2).
size_t getArduinoLoopTaskStackSize() { return 6144; }
static void setupSafeMode(int crashes);
static const char* helpText();
static void shellRun(const std::string& line);
static ShellApp::Target shellProbe(const std::string& path);
static std::vector<std::string> shellList(const std::string& folder, const std::string& prefix, bool& more);
static int shellCount(const std::string& pattern, bool& more);
void setup() {
nvs.begin();
UpdateService::bootGuard(nvs); // first: before anything that could crash on new firmware
int crashes = crash_report::noteBoot(nvs);
safeMode = SafeMode::active(crashes);
Serial.setRxBufferSize(2 * FileReceiver::kChunk); // before the port opens; sd put sends 1 chunk at a time
Serial.setTxBufferSize(1024); // the console skips Serial when it's full: room for a burst like `tasks`
auto cfg = M5.config();
M5Cardputer.begin(cfg, true);
M5Cardputer.Display.setRotation(1);
Serial.begin(115200);
settings.load();
if (safeMode) return setupSafeMode(crashes);
battery = new BatteryService(bus);
storageService = new StorageService(bus);
power = new PowerService(settings);
clockService = new ClockService(settings, bus);
geminiService = new GeminiService(nvs, *storageService, *clockService, bus);
gnssService = new GnssService(settings, *clockService, *storageService, bus);
radioService = new RadioService();
savedNetworks = new SavedNetworks(nvs);
savedNetworks->load();
wifi = new WifiService(settings, *savedNetworks, *clockService);
update = new UpdateService(nvs, *wifi, *savedNetworks, *storageService, bus, settings);
fileOps = new FileOps(*storageService, filesInUse);
update->enableDailyCheck();
update->holdMemory = [](bool hold) { irc->holdForUpdate(hold); }; // see UpdateService::holdMemory
irc = new IrcService(nvs, "roro_" + identity::defaultShortName(), *wifi, *storageService, *clockService, bus);
notifier = new Notifier(bus, settings);
notifier->onShow = [](uint32_t now, uint32_t until) { power->onNotification(now, until); };
services.add(*power);
services.add(*clockService);
services.add(*battery);
services.add(*gnssService);
services.add(*radioService);
loraCapture = new LoraCaptureService(*radioService, *storageService, *clockService, bus);
noiseTest = new NoiseTest(*radioService);
services.add(*loraCapture);
services.add(*storageService);
services.add(*wifi);
services.add(*irc);
services.add(*update);
debugConsole = new DebugConsole(*wifi, *storageService, settings);
services.add(*debugConsole);
vpnService = new VpnService(settings, *wifi, *clockService, bus);
services.add(*vpnService);
apps = new AppManager(launcher);
launcher.setManager(*apps);
apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)});
apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService)});
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)});
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi))});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)});
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
power->beforePowerOff = []() { apps->home(); };
netTools.ntpServer = []() { return settings.getString(Setting::Ntp1); };
// A long note being rewritten (issue #47): the editor waits for the card, so it draws from there.
NoteEditor::onProgress = [](const std::string& name, int percent) { screen.renderUpdate("Saving", name, percent); };
apps->registerApp({"system", "System", false,
new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)});
apps->registerApp({"settings", "Settings", false,
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update, *vpnService})});
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)});
bus.subscribe(EventType::Notification, [](const Event& e) { console.printf("notification: %s\n", e.text); });
if (!screen.begin()) console.println("frame buffer allocation failed");
debugConsole->setFrame(screen.canvas()); // for `screenshot`
services.startAll(millis());
apps->begin();
if (!settings.getBool(Setting::SetupDone)) apps->openModal("setup");
// No screen names its keys any more (issue #69): Setup teaches the help key to a new device, and
// one that was set up before this firmware is told once, here.
if (!settings.getBool(Setting::HelpTold)) {
if (settings.getBool(Setting::SetupDone))
bus.publish(Event::withText(EventType::Notification, "Fn+h: the keys of any screen", static_cast<int32_t>(NotificationLevel::Info)));
settings.setBool(Setting::HelpTold, true);
}
console.printf("%s %s ready, free heap %u, last start: %s\n", kProductName, versionString(), ESP.getFreeHeap(),
system_info::resetReason());
if (system_info::resetWasCrash()) {
crash_report::print(console, nvs);
bus.publish(Event::withText(EventType::Notification, crash_report::headline().c_str(),
static_cast<int32_t>(NotificationLevel::Warning)));
}
// A main loop stuck for 5 s panics (and leaves a core dump) instead of hanging forever: Arduino
// only watches the idle task on core 0, and the loop runs on core 1.
enableLoopWDT();
}
// 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);
savedNetworks = new SavedNetworks(nvs);
savedNetworks->load();
wifi = new WifiService(settings, *savedNetworks, *clockService);
storageService = new StorageService(bus); // never started: no card access
update = new UpdateService(nvs, *wifi, *savedNetworks, *storageService, bus, settings);
services.add(*clockService);
services.add(*wifi);
services.add(*update);
debugConsole = new DebugConsole(*wifi, *storageService, settings);
services.add(*debugConsole);
bus.subscribe(EventType::Notification, [](const Event& e) { console.printf("notification: %s\n", e.text); });
screen.begin();
debugConsole->setFrame(screen.canvas());
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);
crash_report::print(console, nvs);
enableLoopWDT();
}
// Dev aid: commands to drive the UI without the keyboard, from the serial port or the Debug Console.
static bool listingWanted = false;
// Who asked for each reply that is printed later (Console::Origin): the Shell sees its own, only.
static Console::Origin listingFrom, fileOpFrom, updateFrom, tasksFrom;
static void printListingWhenReady() {
if (!listingWanted || !storageService->listingReady()) return;
listingWanted = false;
Console::As as(listingFrom);
auto listing = storageService->listing();
for (size_t i = 0; i < listing.size(); i++) {
uint64_t bytes = 0;
for (auto& f : listing[i]) bytes += f.bytes;
console.printf("sd list: %-20s %u files, %s\n", kCleanupCategories[i].label, (unsigned)listing[i].size(),
formatBytes(bytes).c_str());
for (auto& f : listing[i]) console.printf(" %s (%u B)\n", f.path.c_str(), (unsigned)f.bytes);
}
}
// sd put: a file sent over serial by scripts/sd_put.py. The main loop reads the bytes; every card
// access runs as a Storage Service job, which reports back through uploadResult.
static FileReceiver upload;
static std::atomic<int> uploadResult{-1}; // set by a job: 1 ok, 0 failed; -1 nothing new
static bool uploadJobRunning = false;
static void uploadJob(std::function<bool()> job) {
uploadJobRunning = true;
storageService->runJob([job]() { uploadResult = job() ? 1 : 0; });
}
static void startUpload(const String& args) {
if (upload.active() || uploadJobRunning) return (void)Serial.println("sd put: error busy");
std::string error = upload.begin(args.c_str(), millis());
StorageState card = storageService->state();
if (error.empty() && !card.present) error = "no SD card";
if (error.empty() && card.totalBytes - card.usedBytes < upload.size() + 64 * 1024) error = "not enough space";
if (!error.empty()) {
upload.reset();
return (void)Serial.printf("sd put: error %s\n", error.c_str());
}
std::string part = upload.partPath();
uploadJob([part]() {
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())) return false;
}
if (SD.exists(part.c_str())) SD.remove(part.c_str());
return true;
});
}
static void readUploadBytes() {
uint8_t buf[256];
size_t want;
while (!uploadJobRunning && (want = std::min(upload.wanted(), sizeof buf)) > 0 && Serial.available() > 0) {
size_t n = Serial.read(buf, std::min<size_t>(want, Serial.available()));
upload.feed(buf, n, millis());
}
}
static void uploadStep() {
using S = FileReceiver::State;
uint32_t now = millis();
int result = uploadResult.exchange(-1);
if (result >= 0) {
uploadJobRunning = false;
bool ok = result == 1;
switch (upload.state()) {
case S::Receiving: // the folder job from startUpload: the sender may start
if (ok) Serial.printf("sd put: ready %u\n", (unsigned)FileReceiver::kChunk);
else {
upload.reset();
return (void)Serial.println("sd put: error card not writable");
}
break;
case S::Writing:
upload.chunkWritten(ok, now);
if (upload.state() == S::Receiving) Serial.printf("sd put: ok %u\n", (unsigned)upload.received());
break;
case S::Finishing: upload.finished(ok); break;
default: break; // a job that finished after the transfer failed
}
}
if (!upload.active()) return;
upload.tick(now);
std::string path = upload.path(), part = upload.partPath();
if (upload.state() == S::Failed) {
// Reported even if a job never came back (no card: the storage task drops it).
Serial.printf("sd put: error %s\n", upload.error().c_str());
storageService->runJob([part]() { SD.remove(part.c_str()); });
upload.reset();
uploadJobRunning = false;
return;
}
if (uploadJobRunning) return;
if (upload.state() == S::Writing) {
auto chunk = std::make_shared<std::vector<uint8_t>>(upload.chunk());
uploadJob([chunk, part]() {
File f = SD.open(part.c_str(), FILE_APPEND);
bool ok = f && f.write(chunk->data(), chunk->size()) == chunk->size();
if (f) f.close();
return ok;
});
} else if (upload.state() == S::Finishing) {
uploadJob([path, part]() {
if (SD.exists(path.c_str())) SD.remove(path.c_str());
return SD.rename(part.c_str(), path.c_str());
});
} else if (upload.state() == S::Done) {
Serial.printf("sd put: done %s %u B\n", path.c_str(), (unsigned)upload.size());
upload.reset();
}
}
// `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 {
bool active = false;
uint32_t untilMs = 0;
std::string ssid;
bool wasFixed = false;
net::FixedIp was;
} ipTrial;
static void ipTrialStep() {
if (!ipTrial.active || static_cast<int32_t>(millis() - ipTrial.untilMs) < 0) return;
ipTrial.active = false;
savedNetworks->setIp(ipTrial.ssid, ipTrial.wasFixed ? &ipTrial.was : nullptr);
console.printf("wifi ip: trial over, %s is back to %s\n", ipTrial.ssid.c_str(),
ipTrial.wasFixed ? net::formatFixed(ipTrial.was).c_str() : "Automatic (DHCP)");
wifi->ipSettingChanged(ipTrial.ssid);
}
// `tasks`: the first sample, and when to take the second and print.
static std::vector<TaskSample> tasksBefore;
static uint32_t tasksTotal = 0, tasksDueMs = 0;
static bool tasksPending = false;
// What the firmware is writing right now: the Storage App and the console leave those alone (Q130).
static std::vector<std::string> filesInUse(const std::string& path, bool folder) {
std::vector<std::string> open;
if (gnssService->tracking()) open.push_back(gnssService->trackPath());
if (loraCapture->capturing()) open.push_back(loraCapture->path());
if (upload.active()) open.push_back(upload.partPath());
if (irc->running() && path != "/irc" && files::isInside(path, "/irc")) {
// Today's Logs, and any folder that may hold one: the IRC Service appends whenever a line comes.
std::string date = clockService->localDate();
std::string today = (date.empty() ? "undated" : date) + ".log";
if (folder) open.push_back(files::joinPath(path, today));
else if (files::baseName(path) == today) open.push_back(path);
}
return open;
}
// `cp`, `mv`, `rm`, `mkdir`, `du` run as the Storage App's operations do; the result prints here.
static bool consoleFileOp = false;
// A pattern in a path (issue #67): `rm /notes/*.txt` is one `rm` for each name it matches, run one
// after the other, each when the one before is done. 64 at most: more is refused, not started.
constexpr size_t kMaxGlob = 64;
struct QueuedFileCommand {
std::string line;
Console::Origin from;
};
static std::deque<QueuedFileCommand> fileQueue;
static void fileCommand(const String& line);
// The paths a pattern matches, sorted. `more` when there were over kMaxGlob (or over 2000 entries to
// look through); empty when the pattern isn't in the last part of the path alone.
static std::vector<std::string> expandGlob(const std::string& path, bool& more) {
std::vector<std::string> out;
std::string folder, pattern;
more = false;
if (!files::splitGlob(path, folder, pattern) || !storageService->state().present) return out;
storageService->runAndWait([&]() {
File dir = SD.open(folder.c_str());
if (!dir || !dir.isDirectory()) return;
int seen = 0;
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
if (++seen > 2000 || out.size() > kMaxGlob) {
more = true;
break;
}
if (files::globMatch(pattern, f.name())) out.push_back(files::joinPath(folder, f.name()));
}
});
if (out.size() > kMaxGlob) more = true;
std::sort(out.begin(), out.end());
return out;
}
static void fileQueueStep() {
if (fileQueue.empty() || consoleFileOp) return; // the one before is still running
QueuedFileCommand next = std::move(fileQueue.front());
fileQueue.pop_front();
Console::As as(next.from);
fileCommand(next.line.c_str());
}
static void fileOpsStep() {
FileOps::Status s;
if (!consoleFileOp || !fileOps->finished(s)) return;
consoleFileOp = false;
Console::As as(fileOpFrom);
static const char* const kNames[] = {"", "ls", "du", "cp", "mv", "rm", "mkdir"};
const char* name = kNames[static_cast<int>(s.op)];
if (!s.error.empty()) console.printf("%s: error %s\n", name, s.error.c_str());
else if (s.cancelled) console.printf("%s: cancelled\n", name);
else if (s.op == FileOps::Op::Count) console.printf("du: %s, %u files, %u bytes\n", s.path.c_str(), (unsigned)s.files, (unsigned)s.bytes);
else if (s.op == FileOps::Op::Copy) console.printf("cp: ok %u files, %u bytes in %.1f s\n", (unsigned)s.files, (unsigned)s.doneBytes, s.ms / 1000.0);
else if (s.op == FileOps::Op::Delete) console.printf("rm: ok %u files in %.1f s\n", (unsigned)s.files, s.ms / 1000.0);
else console.printf("%s: ok\n", name);
}
// `update ...`: the project's releases on Gitea (R1, #6). The answers come from the Update Service's
// task a moment later; updateStep() prints them.
static int updateWatch = 0; // 1: a check, 2: a list
static void printReleases(bool list) {
GiteaReleases& g = update->gitea();
if (g.status() == GiteaReleases::Status::Failed) return (void)console.printf("update: %s\n", g.error().c_str());
std::string running = update->runningVersion();
if (!list) {
release::Release r;
if (!g.latest(r)) return (void)console.println("update: nothing known yet");
console.printf("update: latest %s of %s, %u bytes: %s (running %s)\n", r.tag.c_str(), r.date().c_str(), (unsigned)r.otaSize,
release::isNewer(r, running) ? "newer" : "not newer", running.c_str());
console.printf("update: %s\n", r.notes.c_str());
return;
}
for (auto& r : g.list())
console.printf("update: %-8s %s %8u B %s\n", r.tag.c_str(), r.date().c_str(), (unsigned)r.otaSize, r.notes.substr(0, 60).c_str());
console.println("update: end of list");
}
static void updateStep() {
if (!updateWatch || update->giteaBusy()) return;
Console::As as(updateFrom);
if (updateWatch == 3) {
console.printf("update: probe: %s\n", update->probeResult().c_str());
updateWatch = 0;
return;
}
printReleases(updateWatch == 2);
updateWatch = 0;
}
static void updateCommand(const String& args) {
if (args == "check" || args == "list") {
if (update->giteaBusy()) return (void)console.println("update: busy");
args == "check" ? update->requestCheck() : update->requestList();
updateWatch = args == "check" ? 1 : 2;
updateFrom = console.origin();
} else if (args == "status") {
GiteaReleases& g = update->gitea();
console.printf("update: running %s, failed here before: %s, daily check %s, heap %u\n", update->runningVersion().c_str(),
update->failedVersion().empty() ? "none" : update->failedVersion().c_str(),
settings.getBool(Setting::CheckUpdates) ? "on" : "off", (unsigned)ESP.getFreeHeap());
console.printf("update: gitea %s %s\n", g.status() == GiteaReleases::Status::Done ? "done" : g.status() == GiteaReleases::Status::Failed ? "failed" : g.status() == GiteaReleases::Status::Busy ? "busy" : "never asked", g.error().c_str());
printReleases(false);
} else if (args.startsWith("install ")) {
String rest = args.substring(8);
std::string why = update->requestInstall(rest.c_str());
console.printf("update: %s\n", why.empty() ? "installing" : why.c_str());
} else if (args.startsWith("probe ")) { // update probe <host> [path]: is that server's certificate accepted?
String rest = args.substring(6);
int space = rest.indexOf(' ');
update->requestProbe((space < 0 ? rest : rest.substring(0, space)).c_str(), space < 0 ? "/" : rest.substring(space + 1).c_str());
updateWatch = 3;
updateFrom = console.origin();
} else if (args.startsWith("damage ")) { // update damage cut <bytes> | flip <offset>: for the next download
long n = args.substring(args.lastIndexOf(' ') + 1).toInt();
args.startsWith("damage cut") ? update->damageNextDownload(n, -1) : update->damageNextDownload(-1, n);
console.printf("update: the next download will be %s at byte %ld\n", args.startsWith("damage cut") ? "cut" : "damaged", n);
} else if (args == "daily") { // forget today's check: the daily one runs again at once, if it may
update->forgetToday();
console.println("update: the daily check will run at the next tick if Wi-Fi, the clock and memory allow");
} 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());
} else {
console.println("update: check | list | status | install <tag>");
}
}
static void fileCommand(const String& line) {
int space = line.indexOf(' ');
std::string command = line.substring(0, space).c_str(), rest = line.substring(space + 1).c_str();
const std::string everything = rest; // rm reads its own switches
bool force = rest.rfind("-f ", 0) == 0; // replace what's there
if (force) rest = rest.substr(3);
size_t split = rest.find('\t'); // two paths: a tab between them if either has a space
if (split == std::string::npos) split = rest.find(' ');
std::string a = rest.substr(0, split), b = split == std::string::npos ? "" : rest.substr(split + 1);
std::string why;
bool exists = false, folder = false;
if (command == "cancel") {
fileQueue.clear(); // and what a pattern had lined up
return fileOps->cancel();
}
// A pattern: the same command for each name it matches.
files::RmArgs rmArgs = command == "rm" ? files::parseRm(everything) : files::RmArgs();
const std::string globbed = command == "rm" ? rmArgs.path : command == "du" ? rest : command == "cp" || command == "mv" ? a : "";
if (files::hasGlob(globbed) && storageService->state().present) {
bool more = false, intoExists = false;
std::string folder, pattern;
std::vector<std::string> paths = expandGlob(globbed, more);
if (!files::splitGlob(globbed, folder, pattern)) why = "a pattern goes in the last part of a path: /notes/*.txt";
else if (more) why = "more than 64 match: a narrower pattern, please";
else if (paths.empty()) why = "nothing matches";
else if ((command == "cp" || command == "mv") && (b.empty() || !fileOps->isFolder(b, intoExists) || !intoExists)) why = "several files go into a folder that exists";
if (!why.empty()) return (void)console.printf("%s: error %s\n", command.c_str(), why.c_str());
console.printf("%s: %u match %s\n", command.c_str(), (unsigned)paths.size(), globbed.c_str());
Console::Origin from = console.origin();
for (auto& p : paths) {
std::string one = command == "rm" ? std::string("rm ") + (rmArgs.recursive ? "-r " : "") + p
: command == "du" ? "du " + p
: command + (force ? " -f " : " ") + p + "\t" + b; // a tab between two paths: either may hold a space
fileQueue.push_back({one, from});
}
return;
}
if (!storageService->state().present) why = "no SD card";
else if (command == "du") why = fileOps->count(rest);
else if (command == "mkdir") why = fileOps->makeFolder(rest);
else if (command == "rm") { // as Unix has it: a folder needs -r. The questions are the Shell's (-f is its)
files::RmArgs args = files::parseRm(everything);
folder = fileOps->isFolder(args.path, exists);
if (args.path.empty()) why = "rm [-r] [-f] <path>";
else if (!exists) why = "It isn't there";
else if (folder && !args.recursive) why = "It's a folder: rm -r removes it and what's in it";
else why = fileOps->remove(args.path, folder);
} else if (b.empty()) why = "two paths, please";
else {
folder = fileOps->isFolder(a, exists);
bool intoExists = false;
bool into = fileOps->isFolder(b, intoExists); // `cp a /folder` keeps the name
if (!exists) why = "It isn't there";
else if (command == "cp") why = into ? fileOps->copy(a, folder, b, files::baseName(a), force) : fileOps->copy(a, folder, files::parentOf(b), files::baseName(b), force);
else why = fileOps->move(a, folder, into ? files::joinPath(b, files::baseName(a)) : b, force);
}
if (!why.empty()) return (void)console.printf("%s: error %s\n", command.c_str(), why.c_str());
consoleFileOp = true;
fileOpFrom = console.origin();
}
static uint32_t loopPasses = 0; // counted in loop(), for `tasks` (issue #40)
static bool loopSpin = false; // `loop spin on`: no rest between passes, to compare load and radio noise
static uint32_t tasksPasses = 0;
static void tasksStep() {
if (!tasksPending || static_cast<int32_t>(millis() - tasksDueMs) < 0) return;
tasksPending = false;
Console::As as(tasksFrom);
system_info::printTasks(console, tasksBefore, tasksTotal);
console.printf("loop: %lu passes in the last second, chip %.1f C\n", (unsigned long)(loopPasses - tasksPasses), temperatureRead());
std::vector<TaskSample>().swap(tasksBefore);
}
static const char* const kHelp =
"info firmware, uptime, memory, Wi-Fi, app slots\n"
"tasks FreeRTOS tasks over the next second: state, priority, free stack, CPU share\n" "net bytes each network service has read and written since boot\n"
"reboot restart\n"
"boot other restart into the other app slot (manual Rollback)\n"
"log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n"
"ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask; * and ? in a name: /notes/*.txt)\n"
"screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause\n"
"lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only\n"
"lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)\n"
"lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)\n"
"lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)\n"
"gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)\n"
"gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
"crash the last crash: firmware, reason, task, backtrace\n"
"coredump erase forget the core dump in flash\n"
"key <name|char> press a key: up down left right select back home del tab space help shot, or one character; ctrl- alt- shift- before it (key ctrl-down)\n"
"wifi status | wifi add <ssid><TAB><password>\n"
"wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting\n"
"wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers\n"
"gemini get <url> fetch a Gemini page and report header, size, certificate, heap\n"
"irc start | irc stop | irc dump | irc say <buffer> <text>\n"
"install <path.ota> Update from SD\n"
"update check | update list | update status | update install <tag> the project's releases on Gitea\n"
"sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
"Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command\n"
"ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time\n"
"tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one\n"
"ifconfig | arp | netstat the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard; what listens and what is connected\n"
"vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself\n"
"debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\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"
"lora noise test [gnss|quiet] | lora noise report Sweep under one changed condition at a time (Wi-Fi goes off for a moment)\n"
"lora inject <hex> [rssi] [snr] a packet into the LoRa Scanner as if received (nothing is sent)\n"
"sd fill <folder> <count> makes that many small files there, to test a crowded folder\n"
"coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump\n"
"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: there, a bare `screenshot` sends the screen instead of saving it\n"
"quit close the Debug Console connection\n"
;
static const char* helpText() { return kHelp; }
// `screenshot [seconds]` (issue #67, Q209): the frame as it is composed, as a PNG on the card. The
// pause is for getting to the screen you want: from the Shell, "now" is always the Shell.
static bool shotPending = false;
static Console::Origin shotFrom;
static uint32_t shotDueMs = 0;
static std::atomic<bool> shotSaved{false};
static void saveScreenshot() {
if (!storageService || !storageService->state().present) return (void)console.println("screenshot: error no SD card");
char name[40];
int64_t now = clockService ? clockService->utcNow() : -1;
if (now >= 0) {
time_t t = static_cast<time_t>(now);
struct tm local;
localtime_r(&t, &local);
snprintf(name, sizeof name, "/screenshots/%04d%02d%02d-%02d%02d%02d.png", local.tm_year + 1900, local.tm_mon + 1, local.tm_mday,
local.tm_hour, local.tm_min, local.tm_sec);
} else snprintf(name, sizeof name, "/screenshots/shot-%lu.png", (unsigned long)(millis() / 1000)); // no clock yet
std::string path = name;
Console::Origin from = shotFrom;
storageService->runJob([path, from]() {
Console::As as(from);
Canvas& frame = screen.canvas();
const uint8_t* pixels = static_cast<const uint8_t*>(frame.getBuffer());
int w = frame.width(), h = frame.height();
if (!pixels) return (void)console.println("screenshot: error no frame");
if (!SD.exists("/screenshots")) SD.mkdir("/screenshots");
File f = SD.open(path.c_str(), FILE_WRITE);
if (!f) return (void)console.printf("screenshot: error the card refused to make %s\n", path.c_str());
png::Rgb332Writer writer(w, h, [&f](const uint8_t* data, size_t len) { return f.write(data, len) == len; });
bool ok = writer.begin();
for (int y = 0; ok && y < h; y++) ok = writer.row(pixels + y * w); // read as it stands: it may tear
ok = ok && writer.end();
f.close();
if (!ok) {
SD.remove(path.c_str());
return (void)console.println("screenshot: error the card refused a write");
}
console.printf("screenshot: saved %s (%u bytes)\n", path.c_str(), (unsigned)png::Rgb332Writer::fileSize(w, h));
shotSaved = true; // the main loop says so on the screen, after the picture is taken
});
}
// `vpn ...` (issue #8). Nothing here prints a key.
static void vpnCommand(const String& arg) {
if (!vpnService) return (void)console.println("vpn: not available in Safe Mode");
VpnService& v = *vpnService;
if (arg == "up" || arg.startsWith("up ")) {
if (!v.configured()) return (void)console.println("vpn: error not set: copy a .conf to /vpn/wg0.conf, then `vpn import`");
uint32_t seconds = arg.length() > 3 ? constrain(arg.substring(3).toInt(), 0, 86400) : 0;
v.want(true, seconds);
if (seconds) console.printf("vpn: on for %lu s\n", (unsigned long)seconds);
else console.println("vpn: on");
} else if (arg == "down") {
v.want(false);
console.println("vpn: off");
} else if (arg == "import" || arg.startsWith("import ")) {
std::string path = arg.length() > 7 ? arg.substring(7).c_str() : "/vpn/wg0.conf";
std::string why = v.importFile(*storageService, path);
if (!why.empty()) return (void)console.printf("vpn: error %s\n", why.c_str());
console.printf("vpn: imported, through it %s. %s still holds the private key: `rm -f` it\n", net::describeWgRouting(v.config()).c_str(), path.c_str());
} else if (arg == "forget") {
v.forget();
console.println("vpn: forgotten");
} else if (arg == "status") {
if (!v.configured()) return (void)console.println("vpn: not set");
const net::WgConfig& k = v.config();
console.printf("vpn: %s%s, server %s:%u, this device %s/%d, through it %s\n", v.wanted() ? "" : "off, ", v.wanted() ? v.stateText() : "configured",
k.endpointHost.c_str(), (unsigned)k.endpointPort, net::formatIpv4(k.address).c_str(), k.prefix, net::describeWgRouting(k).c_str());
int64_t now = clockService->utcNow(), last = v.lastHandshake();
if (last > 0 && now >= last) console.printf("vpn: last handshake %ld s ago\n", (long)(now - last));
if (!v.lastError().empty()) console.printf("vpn: %s\n", v.lastError().c_str());
console.printf("vpn: start with Wi-Fi %s\n", settings.getBool(Setting::VpnAuto) ? "on" : "off");
} else if (arg == "auto on" || arg == "auto off") {
settings.setBool(Setting::VpnAuto, arg == "auto on");
console.printf("vpn: start with Wi-Fi %s\n", arg == "auto on" ? "on" : "off");
} else {
console.println("vpn: status | up [seconds] | down | import [path] | forget | auto on|off");
}
}
// Fn+p (issue #83): the screen as it is, dialog, help panel or Toast included. Not the page that
// shows the Debug Console's token: a picture of it is a copy of the token in a file.
static void screenshotKey() {
const char* why = nullptr;
if (apps->foreground().showsSecret()) why = "No screenshot here: it shows the token";
else if (!storageService || !storageService->state().present) why = "No SD card for the screenshot";
if (why) {
bus.publish(Event::withText(EventType::Notification, why, static_cast<int32_t>(NotificationLevel::Warning)));
return;
}
shotFrom = Console::Origin::System;
saveScreenshot();
}
// Every key goes through here, from the keyboard or from a console's `key`.
static void handleKey(const KeyEvent& e) {
if (e.key == Key::Screenshot) return screenshotKey();
apps->handleKey(e);
}
static void screenshotStep() {
if (shotPending && static_cast<int32_t>(millis() - shotDueMs) >= 0) {
shotPending = false;
saveScreenshot();
}
if (shotSaved.exchange(false))
bus.publish(Event::withText(EventType::Notification, "Screenshot saved in /screenshots", static_cast<int32_t>(NotificationLevel::Info)));
}
// 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.startsWith("debug ");
}
// `debug off <seconds>`: the console closes and comes back by itself. It can't open itself wider
// that way (it was on, with the same token), and it's how its closing and reopening is tested from afar.
static bool debugResumes = false;
static uint32_t debugResumeMs = 0;
static void debugResumeStep() {
if (!debugResumes || static_cast<int32_t>(millis() - debugResumeMs) < 0) return;
debugResumes = false;
settings.setBool(Setting::DebugConsole, true);
}
// `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 (args.startsWith("off ")) { // debug off <seconds>: a pause, then on again as it was
uint32_t seconds = constrain(args.substring(4).toInt(), 1, 600);
if (!settings.getBool(Setting::DebugConsole)) return (void)console.println("debug: it is off");
settings.setBool(Setting::DebugConsole, false);
debugResumeMs = millis() + seconds * 1000;
debugResumes = true;
console.printf("debug: off for %lu s\n", (unsigned long)seconds);
} 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 [seconds] | 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[0] >= 'A' && line[0] <= 'Z') { // an App, by its name with a capital
const AppInfo* app = apps ? apps->byCommand(line.c_str()) : nullptr;
if (!app) {
console.printf("No App called %s:", line.c_str());
if (apps)
for (auto* a : apps->visibleApps()) console.printf(" %s", AppManager::commandFor(a->id).c_str());
console.println();
} else if (apps->open(app->id)) console.printf("%s\n", app->title);
else console.println("Not now: Setup is running");
return;
}
if (netTools.command(line.c_str())) return;
if (line == "cancel") netTools.cancel(); // and a copy or a delete, below
if (line == "vpn" || line.startsWith("vpn ")) return vpnCommand(line.length() > 4 ? line.substring(4) : String("status"));
if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial);
if (line == "screenshot" || line.startsWith("screenshot ")) {
uint32_t seconds = constrain(line.substring(10).toInt(), 0, 60);
shotPending = true;
shotFrom = console.origin();
shotDueMs = millis() + seconds * 1000;
if (seconds) console.printf("screenshot: in %lu s\n", (unsigned long)seconds);
return;
}
// Over the Debug Console these never get here: its own task answers them.
if (line == "coredump get" || line == "reset" || line.startsWith("get ") || line.startsWith("put "))
return (void)console.println("Debug Console only: scripts/rdbg.py speaks it");
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);
if (apps) console.printf("app: %s%s\n", apps->foregroundTitle() ? apps->foregroundTitle() : "Launcher", apps->help().isOpen() ? " (the help panel is open)" : "");
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);
}
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");
}
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;
tasksPasses = loopPasses;
tasksPending = true;
tasksFrom = console.origin();
}
if (line == "net") { // bytes each service has read and written since boot (S1, Q121)
for (int i = 0; i < static_cast<int>(net::User::Count); i++) {
net::Traffic t = net::traffic(static_cast<net::User>(i));
console.printf("net: %-14s in %10lu out %10lu\n", net::userName(static_cast<net::User>(i)), (unsigned long)t.in,
(unsigned long)t.out);
}
}
if (line == "reboot") {
console.println("restarting");
delay(300);
ESP.restart();
}
if (line == "boot other") console.printf("boot other: %s\n", system_info::bootOtherSlot());
if (line.startsWith("log level ")) {
int level = line.substring(10).toInt();
esp_log_level_set("*", static_cast<esp_log_level_t>(constrain(level, 0, 5)));
console.printf("log level: %d\n", constrain(level, 0, 5));
}
if (line.startsWith("update ")) updateCommand(line.substring(7));
if (line.startsWith("cp ") || line.startsWith("mv ") || line.startsWith("rm ") || line.startsWith("mkdir ") ||
line.startsWith("du ") || line == "cancel")
fileCommand(line);
if (line == "ls" || line.startsWith("ls ")) {
if (!storageService->state().present) return (void)console.println("sd: no card");
std::string path = line.length() > 3 ? line.substring(3).c_str() : "/", pattern;
const std::string asked = path;
if (files::hasGlob(path) && !files::splitGlob(asked, path, pattern)) // /notes/*.txt: the folder, and what to show of it
return (void)console.println("ls: error a pattern goes in the last part of a path: /notes/*.txt");
Console::Origin from = console.origin();
storageService->runJob([path, pattern, asked, from]() { // card access stays on the storage task
Console::As as(from);
File dir = SD.open(path.c_str());
if (!dir || !dir.isDirectory()) return (void)console.printf("ls: %s is not a folder\n", path.c_str());
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
if (!pattern.empty() && !files::globMatch(pattern, f.name())) continue;
console.printf("%10u %-16s %s%s\n", f.isDirectory() ? 0u : (unsigned)f.size(),
files::formatStamp(static_cast<uint32_t>(f.getLastWrite())).c_str(), f.name(), f.isDirectory() ? "/" : "");
}
console.printf("ls: end of %s\n", asked.c_str());
});
}
if (line.startsWith("install ")) update->installFromSd(line.substring(8).c_str()); // Update from SD
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();
int count = constrain(line.substring(space + 1).toInt(), 0, 1000);
Console::Origin from = console.origin();
storageService->runJob([folder, count, from]() {
Console::As as(from);
int made = 0;
for (int i = 1; i <= count; i++) {
char name[24];
snprintf(name, sizeof name, "file-%04d.txt", i);
File f = SD.open(files::joinPath(folder, name).c_str(), FILE_WRITE);
if (!f) break;
f.printf("file %d\n", i);
f.close();
made++;
}
console.printf("sd fill: %d files in %s\n", made, folder.c_str());
});
}
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"));
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
// Debug Console: the report prints when it's over, and `lora noise report` shows it again.
using C = NoiseTest::Condition;
auto radioOptions = [](bool ldo, bool boosted) { return [=]() { radioService->debugOptions(ldo, boosted); }; };
std::vector<C> conditions = {
{"as it is", nullptr, nullptr},
{"loop spinning", []() { loopSpin = true; }, []() { loopSpin = false; }},
{"GNSS in standby", []() { gnssService->send("PCAS12,30"); }, []() { gnssService->restart(millis()); }, 2500},
{"CPU at 160 MHz", []() { setCpuFrequencyMhz(160); }, []() { setCpuFrequencyMhz(240); }},
{"CPU at 80 MHz", []() { setCpuFrequencyMhz(80); }, []() { setCpuFrequencyMhz(240); }},
{"radio regulator: LDO", radioOptions(true, true), radioOptions(false, true)},
{"radio gain not boosted", radioOptions(false, false), radioOptions(false, true)},
{"as it is, again", nullptr, nullptr},
{"Wi-Fi off", []() { wifi->debugPause(true); }, []() { wifi->debugPause(false); }, 4000},
{"screen on", []() { power->onKey(millis()); }, nullptr, 1000},
{"as it is, at the end", nullptr, nullptr, 8000},
};
noiseTest->start(std::move(conditions));
}
if (line == "lora noise test quiet" && noiseTest && !noiseTest->running()) {
// The same conditions as the first test, with the GNSS receiver in standby throughout: with
// the loudest source out of the way, what else shows?
using C = NoiseTest::Condition;
auto radioOptions = [](bool ldo, bool boosted) { return [=]() { radioService->debugOptions(ldo, boosted); }; };
std::vector<C> conditions = {
{"GNSS on (reference)", nullptr, nullptr},
{"GNSS in standby", []() { gnssService->send("PCAS12,300"); }, nullptr, 2500},
{"+ loop spinning", []() { loopSpin = true; }, []() { loopSpin = false; }},
{"+ CPU at 160 MHz", []() { setCpuFrequencyMhz(160); }, []() { setCpuFrequencyMhz(240); }},
{"+ CPU at 80 MHz", []() { setCpuFrequencyMhz(80); }, []() { setCpuFrequencyMhz(240); }},
{"+ radio regulator: LDO", radioOptions(true, true), radioOptions(false, true)},
{"+ radio gain not boosted", radioOptions(false, false), radioOptions(false, true)},
{"GNSS in standby, again", nullptr, nullptr},
{"+ Wi-Fi off", []() { wifi->debugPause(true); }, []() { wifi->debugPause(false); }, 4000},
{"+ screen on", []() { power->onKey(millis()); }, nullptr, 1000},
{"+ antenna switched off", []() { radioService->debugAntenna(false); }, []() { radioService->debugAntenna(true); }},
{"+ antenna off, gain not boosted", []() { radioService->debugAntenna(false); radioService->debugOptions(false, false); },
[]() { radioService->debugAntenna(true); radioService->debugOptions(false, true); }},
{"GNSS in standby, at the end", nullptr, []() { gnssService->restart(millis()); }, 8000},
};
noiseTest->start(std::move(conditions));
}
if (line == "lora noise test gnss" && noiseTest && !noiseTest->running()) {
// The first test pointed at the GNSS receiver. Is it the receiver working, or its serial
// line (about 20 sentences a second, next to the antenna)? Standby stops both; one sentence
// a second quietens the line and leaves the receiver tracking. PCAS03 picks the sentences:
// GGA, GLL, GSA, GSV, RMC, VTG, ZDA, ANT; all eight are on by default.
using C = NoiseTest::Condition;
auto standby = []() { gnssService->send("PCAS12,30"); };
auto wake = []() { gnssService->restart(millis()); };
std::vector<C> conditions = {
{"as it is", nullptr, nullptr},
{"GNSS in standby", standby, wake, 2500},
{"GNSS on again", nullptr, nullptr, 5000},
{"GNSS: RMC only", []() { gnssService->send("PCAS03,0,0,0,0,1,0,0,0,0,0,,,0,0"); },
[]() { gnssService->send("PCAS03,1,1,1,1,1,1,1,1,0,0,,,0,0"); }, 2500},
{"GNSS: all sentences again", nullptr, nullptr, 3000},
{"GNSS in standby, again", standby, wake, 2500},
{"GNSS on again", nullptr, nullptr, 5000},
};
noiseTest->start(std::move(conditions));
}
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") {
radioService->setSweepEcho(false);
radioService->sweep(false);
} else if (line.startsWith("lora sweep on")) {
double from = 863, to = 870, step = 100;
sscanf(line.c_str() + 13, "%lf %lf %lf", &from, &to, &step);
radioService->setSweepEcho(true);
radioService->sweep(true, from * 1e6 + 0.5, to * 1e6 + 0.5, step * 1e3 + 0.5);
}
}
if (line == "lora capture start" && loraCapture) {
std::string why = loraCapture->start(millis());
console.printf("lora capture: %s\n", why.empty() ? loraCapture->path().c_str() : why.c_str());
}
if (line == "lora capture stop" && loraCapture) loraCapture->stop();
if (line.startsWith("lora inject ") && radioService) { // <hex> [rssi] [snr]: as if received
String hex = line.substring(12);
int space = hex.indexOf(' ');
float rssi = -100, snr = 5;
if (space > 0) sscanf(hex.c_str() + space + 1, "%f %f", &rssi, &snr), hex = hex.substring(0, space);
uint8_t data[255];
size_t n = 0;
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);
}
if (line.startsWith("lora custom ") && radioService) {
double mhz = 0, bw = 0;
unsigned sf = 0, cr = 0, sync = 0, preamble = 8;
int n = sscanf(line.c_str() + 12, "%lf %lf %u %u %x %u", &mhz, &bw, &sf, &cr, &sync, &preamble);
// Inside what the Cap's SX1262 tunes (868 to 923 MHz) and LoRa's own limits.
if (n < 5 || mhz < 863 || mhz > 928 || bw < 7 || bw > 500 || sf < 5 || sf > 12 || cr < 5 || cr > 8 || sync > 0xFF)
console.println("lora custom: usage: lora custom <MHz> <BW kHz> <SF 5-12> <CR 5-8> <sync hex> [preamble]");
else {
radioService->setConfig({static_cast<uint32_t>(mhz * 1e6 + 0.5), static_cast<float>(bw), static_cast<uint8_t>(sf),
static_cast<uint8_t>(cr), static_cast<uint8_t>(sync), static_cast<uint16_t>(preamble), "Custom"});
console.printf("lora custom: %.3f MHz, BW %.1f kHz, SF %u, CR 4/%u, sync 0x%02X, preamble %u\n", mhz, bw, sf, cr, sync, preamble);
}
}
if (line.startsWith("lora preset ") && radioService) {
const meshtastic::Preset* p = meshtastic::findPreset(line.substring(12).c_str());
if (p) radioService->setPreset(*p);
console.printf("lora preset: %s\n", p ? p->name : "unknown (LongFast LongSlow MediumSlow MediumFast ShortSlow ShortFast LongMod)");
}
if (line == "gnss quiet on" || line == "gnss quiet off") { // Settings > Pause GNSS for LoRa (issue #20)
settings.setBool(Setting::GnssQuietForLora, line.endsWith("on"));
console.printf("gnss quiet: %s\n", line.endsWith("on") ? "GNSS pauses while the LoRa radio listens" : "GNSS stays on");
}
if (line == "gnss status" && gnssService) gnssService->printStatus(console, millis());
if ((line == "gnss nmea on" || line == "gnss nmea off") && gnssService) gnssService->setEcho(line.endsWith("on"));
if (line == "gnss restart" && gnssService) gnssService->restart(millis());
if (line == "gnss track start" && gnssService) {
std::string why = gnssService->startTrack(millis());
console.printf("gnss track: %s\n", why.empty() ? "recording" : why.c_str());
}
if (line == "gnss track stop" && gnssService) gnssService->stopTrack();
if (line.startsWith("gnss send ") && gnssService) // the checksum is added
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");
if (line == "crash abort") abort();
if (line == "crash wdt")
for (;;) {} // the main loop never yields: the task watchdog fires
if (line == "burst")
for (int i = 1; i <= 5; i++)
bus.publish(Event::withText(EventType::Notification, ("Burst " + String(i)).c_str(), 0));
if (line.startsWith("key ")) { // key up|down|left|right|select|back|home|del|tab
String k = line.substring(4);
// ctrl- alt- shift- before the name, in any order: `key ctrl-down`, `key alt-up`, `key ctrl-b`.
bool ctrl = false, alt = false, shift = false;
for (bool more = true; more;) {
more = false;
if (k.startsWith("ctrl-") && k.length() > 5) ctrl = more = true, k = k.substring(5);
if (k.startsWith("alt-") && k.length() > 4) alt = more = true, k = k.substring(4);
if (k.startsWith("shift-") && k.length() > 6) shift = more = true, k = k.substring(6);
}
Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left
: k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home
: k == "del" ? Key::Delete : k == "tab" ? Key::Tab : k == "help" ? Key::Help : k == "shot" ? Key::Screenshot : Key::Select;
KeyEvent ev = k == "space" ? KeyEvent::character(' ')
: k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0])
: KeyEvent::of(key);
ev.ctrl = ctrl;
ev.alt = alt;
ev.shift = shift;
if (!power->onKey(millis())) handleKey(ev);
}
if (line.startsWith("wifi add ")) { // wifi add <ssid>\t<password>: credentials never touch the repo
int tab = line.indexOf('\t');
std::string ssid = (tab > 0 ? line.substring(9, tab) : line.substring(9)).c_str();
std::string pass = tab > 0 ? line.substring(tab + 1).c_str() : "";
std::string error = savedNetworks->add(ssid, pass);
console.printf("wifi add: %s\n", error.empty() ? "ok" : error.c_str());
wifi->savedNetworksChanged();
}
if (line.startsWith("log ") && !line.startsWith("log level ")) { // appends to a test IRC Log
std::string path = storage::dailyLogPath({"irc", "dev", "#test"}, clockService->localDate());
storageService->appendLine(path, clockService->displayTime() + " " + line.substring(4).c_str());
console.printf("log: queued to %s\n", path.c_str());
}
if (line.startsWith("cat ")) {
std::string path = line.substring(4).c_str();
File f = SD.open(path.c_str());
if (!f) console.printf("cat: cannot open %s\n", path.c_str());
else {
console.printf("--- %s (%u B) ---\n", path.c_str(), (unsigned)f.size());
int shown = 0;
while (f.available() && shown < 1200) { console.write(f.read()); shown++; }
console.println("\n--- end ---");
f.close();
}
}
if (line.startsWith("sd put ")) startUpload(line.substring(7)); // then raw bytes: see uploadStep()
if (line == "sd card") { // what the card says it is, from its CID register
if (!storageService->state().present) return (void)console.println("sd card: no card");
Console::Origin from = console.origin();
storageService->runJob([from]() {
Console::As as(from);
uint8_t cid[16];
const char* type = SD.cardType() == CARD_SDHC ? "SDHC/SDXC" : SD.cardType() == CARD_SD ? "SDSC" : "MMC or unknown";
if (!sdReadCid(cid)) return (void)console.println("sd card: the card didn't answer");
console.printf("sd card: %s, %.1f GB, %s\n", type, SD.cardSize() / 1e9, sdCardSummary(parseSdCid(cid)).c_str());
console.printf("sd card: CID");
for (uint8_t b : cid) console.printf(" %02x", b);
console.println();
});
}
if (line == "sd list") {
storageService->requestListing();
listingWanted = true;
listingFrom = console.origin();
}
if (line.startsWith("gemini trust ")) { // gemini trust <host> <port> <sha256>: accept a changed certificate
char host[96] = "", fp[80] = "";
int port = 1965;
if (geminiService && sscanf(line.substring(13).c_str(), "%95s %d %79s", host, &port, fp) == 3) geminiService->trust(host, port, fp);
}
if (line.startsWith("gemini get "))
if (!geminiService || !geminiService->fetchToConsole(line.substring(11).c_str())) console.println("gemini: busy, or unavailable in Safe Mode");
if (line == "irc start") irc->connect();
if (line == "irc stop") irc->disconnect();
if (line.startsWith("irc say ")) { // irc say <buffer index> <text>
String rest = line.substring(8);
int space = rest.indexOf(' ');
int b = rest.substring(0, space).toInt();
std::string text = rest.substring(space + 1).c_str();
irc->withSession([&](IrcSession& s) { s.input(b, text, clockService->utcNow()); });
}
if (line == "irc dump") {
console.printf("irc: status %d, unread %d, heap %u min %u\n", (int)irc->status(), irc->totalUnread(),
ESP.getFreeHeap(), ESP.getMinFreeHeap());
IrcConfig c = irc->draftConfig(); // which login is configured, never the secrets
console.printf("irc: nick %s, sasl %s, nickserv %s, autojoin %u channels\n", c.nick.c_str(),
c.saslUser.empty() || c.saslPassword.empty() ? "off" : "on",
c.nickservPassword.empty() ? "off" : "on", (unsigned)c.autojoin.size());
irc->withSession([](IrcSession& s) {
for (int i = 0; i < s.bufferCount(); i++) {
const auto& b = s.buffer(i);
console.printf("irc buffer %d %s unread %d%s\n", i, b.name.c_str(), b.unread, b.joined ? " joined" : "");
size_t from = 0;
for (size_t j = from; j < b.lines.size(); j++)
console.printf(" <%s> %s\n", b.lines[j].nick.c_str(), b.lines[j].text.c_str());
}
});
}
if (line == "wifi status") {
console.printf("wifi: state %d ssid '%s' rssi %d ip %s clock %s heap %u min %u | fw %s%s\n",
(int)wifi->state(), wifi->ssid().c_str(), wifi->rssi(), wifi->ip().c_str(),
clockService->displayTime().c_str(), ESP.getFreeHeap(), ESP.getMinFreeHeap(), versionString(),
update->onProbation() ? " (on probation)" : "");
WifiService::Connection c = wifi->connection(); // S1, Q115: what's in use and where it came from
if (c.connected) {
console.printf("wifi: address %s/%d (%s), gateway %s\n", c.address.c_str(), c.prefix, c.fixed ? "fixed" : "DHCP",
c.gateway.empty() ? "none" : c.gateway.c_str());
console.printf("wifi: dns %s %s (%s)\n", c.dns[0].empty() ? "none" : c.dns[0].c_str(), c.dns[1].c_str(),
vpnService && vpnService->dnsThroughIt() ? "VPN" : c.dnsFromSettings ? "Settings" : "DHCP");
console.print("wifi: ntp");
for (int i = 0; i < c.ntpCount; i++) console.printf(" %s (%s%s)", c.ntp[i].server.c_str(), c.ntp[i].fromDhcp ? "DHCP" : "Settings", c.ntp[i].answered ? ", answered" : "");
console.println(c.ntpCount ? "" : " none");
}
}
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();
if (rest == "keep") { // the trial setting stays
console.println(ipTrial.active ? "wifi ip: kept" : "wifi ip: no trial running");
ipTrial.active = false;
return;
}
uint32_t trialS = 0;
size_t tryAt = rest.rfind(" try ");
if (tryAt != std::string::npos) {
trialS = strtoul(rest.c_str() + tryAt + 5, nullptr, 10);
rest = rest.substr(0, tryAt);
}
std::string ssid, why;
net::FixedIp fixed;
bool dhcp = rest.size() > 5 && rest.compare(rest.size() - 5, 5, " dhcp") == 0;
if (dhcp) ssid = rest.substr(0, rest.size() - 5);
else {
size_t slash = rest.rfind('/'), space = slash == std::string::npos ? slash : rest.rfind(' ', slash);
if (space == std::string::npos) why = "usage: wifi ip <ssid> dhcp | <address>/<prefix> [gateway]";
else {
ssid = rest.substr(0, space);
why = net::parseFixed(rest.substr(space + 1), fixed);
}
}
const SavedNetwork* before = why.empty() ? savedNetworks->find(ssid) : nullptr;
if (before && trialS) ipTrial = {true, millis() + trialS * 1000, ssid, before->fixed, before->ip};
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());
wifi->ipSettingChanged(ssid);
}
if ((line.startsWith("wifi dns ") && !line.startsWith("wifi dns always")) || line.startsWith("wifi ntp ")) { // <a> [b]
bool dns = line.startsWith("wifi dns ");
std::string rest = line.substring(9).c_str();
size_t space = rest.find(' ');
std::string first = rest.substr(0, space), second = space == std::string::npos ? "" : rest.substr(space + 1);
Setting a = dns ? Setting::Dns1 : Setting::Ntp1, b = dns ? Setting::Dns2 : Setting::Ntp2;
std::string oldFirst = settings.getString(a);
// Both or neither: a refused second entry leaves the first as it was.
bool ok = settings.setString(a, first) && (settings.setString(b, second) || (settings.setString(a, oldFirst), false));
if (!ok) console.printf("wifi %s: %s\n", dns ? "dns" : "ntp", dns ? "an IPv4 address, then a second one if you like" : "a host name or an IPv4 address, then a second one if you like");
else {
console.printf("wifi %s: %s %s\n", dns ? "dns" : "ntp", first.c_str(), second.c_str());
wifi->serversChanged();
}
}
if (line == "wifi dns always on" || line == "wifi dns always off") {
settings.setBool(Setting::DnsAlways, line.endsWith("on"));
wifi->serversChanged();
}
if (line == "sound off") settings.setBool(Setting::Sound, false);
if (line == "sound on") settings.setBool(Setting::Sound, true);
if (line == "short") {
settings.setInt(Setting::DimTimeoutS, 5);
settings.setInt(Setting::OffTimeoutS, 10);
}
if (line == "normal") {
settings.setInt(Setting::OffTimeoutS, 60);
settings.setInt(Setting::DimTimeoutS, 30);
}
}
// What the Shell App runs (issue #67): trusted like USB serial (Q205), and echoed into the console so
// that a session reads the same from afar, except a token being set (Q210).
//
// The App only hands the line over: it is run from the main loop, like the consoles' commands, not
// from inside the key handler. Run from there, `rm` on a folder overflowed the main loop's stack
// (a key from the Debug Console, the App manager, the Shell, then runCommand a second time and
// printf under all of it): the loop has under 2 KB of stack to spare.
static std::deque<std::string> shellLines;
static void shellRun(const std::string& line) {
if (shellLines.size() < 8) shellLines.push_back(line);
}
static void shellCommands() {
while (!shellLines.empty()) {
std::string line = std::move(shellLines.front());
shellLines.pop_front();
Console::As as(Console::Origin::Shell); // what it prints, and what answers it later, is the Shell's
String l = line.c_str();
if (!l.startsWith("debug token ") || l == "debug token new") console.printf("> %s\n", line.c_str());
runCommand(l, true);
}
}
// What a path is, for the Shell to know whether `rm` deserves a question (Q209).
static ShellApp::Target shellProbe(const std::string& path) {
bool exists = false;
bool folder = fileOps->isFolder(path, exists);
if (!exists) return ShellApp::Target::Missing;
if (!folder) return ShellApp::Target::File;
bool empty = true;
storageService->runAndWait([&]() {
File dir = SD.open(path.c_str());
if (dir) {
File first = dir.openNextFile();
empty = !first;
}
});
return empty ? ShellApp::Target::EmptyFolder : ShellApp::Target::FullFolder;
}
// The entries of a folder that Tab could complete to, for the Shell (issue #67). Read on the storage
// task while the main loop waits, so it is held to what a crowded folder allows: 400 entries looked
// at, 24 given back.
static std::vector<std::string> shellList(const std::string& folder, const std::string& prefix, bool& more) {
std::vector<std::string> names;
more = false;
if (!storageService->state().present) return names;
storageService->runAndWait([&]() {
std::string path = folder.size() > 1 ? folder.substr(0, folder.size() - 1) : folder; // without the slash at its end
File dir = SD.open(path.c_str());
if (!dir || !dir.isDirectory()) return;
int seen = 0;
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
if (++seen > 400) {
more = true;
break;
}
std::string name = f.name();
bool fits = name.size() >= prefix.size();
for (size_t i = 0; fits && i < prefix.size(); i++) fits = tolower(static_cast<unsigned char>(name[i])) == tolower(static_cast<unsigned char>(prefix[i]));
if (!fits) continue;
if (names.size() >= 24) {
more = true;
break;
}
names.push_back(name + (f.isDirectory() ? "/" : ""));
}
});
std::sort(names.begin(), names.end());
return names;
}
static int shellCount(const std::string& pattern, bool& more) { return static_cast<int>(expandGlob(pattern, more).size()); }
static void serialCommands() {
if (upload.active()) return readUploadBytes(); // raw file bytes, not commands
static String line;
while (Serial.available()) {
char c = Serial.read();
if (c != '\n') {
line += c;
continue;
}
runCommand(line, true);
line = "";
}
}
static void remoteCommands() {
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());
}
debugResumeStep();
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).
static void noteStableOnce(uint32_t now) {
static bool noted = false;
if (noted || now < SafeMode::kStableAfterMs) return;
noted = true;
crash_report::noteStable(nvs);
}
static void loopSafeMode() {
uint32_t now = millis();
serialCommands();
remoteCommands();
tasksStep();
services.tick(now);
bus.dispatch();
noteStableOnce(now);
if (update->phase() == UpdateService::Phase::Installed) {
screen.renderUpdate("Restarting", "into " + update->incomingVersion(), -1);
delay(800);
ESP.restart();
}
static uint32_t lastDraw = 0;
if (update->phase() == UpdateService::Phase::Receiving) {
screen.renderUpdate("Firmware update", "Receiving " + update->incomingVersion(), update->percent());
} else if (now - lastDraw > 2000 || !lastDraw) {
lastDraw = now;
bool up = wifi->state() == WifiController::State::Connected;
screen.renderUpdate("Safe Mode", up ? "Updates: " + wifi->ip() + ":3232" : "Waiting for Wi-Fi", -1);
}
delay(10);
}
// How long the main loop rests after a pass (issue #40). Spinning, it made 50,000 passes a
// second and kept core 1 100 % busy for nothing: keys are buffered by the keyboard controller,
// the consoles and the radio have their own tasks, and no Service asks for a tick more often than
// every 50 ms. Shorter with the screen on, so a key or a redraw never waits long.
constexpr uint32_t kLoopRestScreenOnMs = 5;
constexpr uint32_t kLoopRestScreenOffMs = 20;
static void loopPass();
void loop() {
loopPasses++;
if (safeMode) {
loopSafeMode();
delay(kLoopRestScreenOnMs);
return;
}
loopPass();
if (loopSpin) return;
if (upload.active()) return; // a serial file transfer: every byte is read promptly
delay(power->screen() == ScreenState::Off ? kLoopRestScreenOffMs : kLoopRestScreenOnMs);
}
static void loopPass() {
#ifdef RORO_TEST_CRASH
// Test builds only (never in a release): crash during Probation to exercise Rollback.
if (millis() > 5000) abort();
#endif
static StatusInfo lastStatus;
static uint32_t lastLog = 0;
uint32_t now = millis();
serialCommands();
remoteCommands();
shellCommands();
tasksStep();
screenshotStep();
ipTrialStep();
if (noiseTest) noiseTest->step(now);
noteStableOnce(now);
updateStep();
fileOpsStep();
fileQueueStep();
uploadStep();
printListingWhenReady();
M5Cardputer.update();
if (M5Cardputer.Keyboard.isChange()) {
bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen
keyMapper.setTextEntry(apps->foreground().textEntryActive());
auto events = keyMapper.update(readKeys()); // always, so held keys are tracked
if (!swallow)
for (auto& e : events) handleKey(e);
}
// Issue #20: the GNSS receiver costs the LoRa radio 8 dB while it runs. If the user chose so,
// it waits in standby while the radio listens or sweeps; never while a Track is recording.
gnssService->holdForLora(settings.getBool(Setting::GnssQuietForLora) && !gnssService->tracking() &&
(radioService->listening() || radioService->sweeping()));
services.tick(now);
bus.dispatch();
notifier->update(now);
apps->update(now);
if (now - lastLog > 10000) {
lastLog = now;
console.printf("status: heap %u min %u\n", ESP.getFreeHeap(), ESP.getMinFreeHeap());
}
// Firmware Update: its progress takes over the screen, then the device restarts when it's safe.
static int shownPercent = -1;
static uint32_t installedAt = 0;
static bool redrawAfterUpdate = false;
switch (update->phase()) {
case UpdateService::Phase::Receiving:
if (update->percent() != shownPercent) {
shownPercent = update->percent();
screen.renderUpdate("Firmware update", "Receiving " + update->incomingVersion(), shownPercent);
}
redrawAfterUpdate = true;
return;
case UpdateService::Phase::Installed:
if (!installedAt) installedAt = now;
// Don't pull the rug out from under someone typing, for up to a minute.
if (!apps->foreground().textEntryActive() || now - installedAt >= 60000) {
screen.renderUpdate("Restarting", "into " + update->incomingVersion(), -1);
delay(800);
ESP.restart();
}
break;
default:
shownPercent = -1;
break;
}
if (power->screen() == ScreenState::Off) return;
StatusInfo status = currentStatus();
const Toast* toast = notifier->toasts().current(now);
bool toastChanged = notifier->toasts().takeChanged();
if (apps->takeRedraw() || toastChanged || status != lastStatus || redrawAfterUpdate) {
lastStatus = status;
redrawAfterUpdate = false;
screen.render(*apps, status, toast);
update->firstFrameDrawn();
}
}