Public Access
Joining a Saved Network uses its Fixed address, mask and gateway, or DHCP. DNS comes from Settings on Fixed networks and when "Always use my DNS" is on; NTP servers come from Settings, after any that DHCP offered. Both are re-checked every 30 s, since a DHCP renewal puts DHCP's DNS back and clears the NTP slots it didn't fill. `wifi status` shows what's in use, where it came from, and which NTP servers answered; `wifi ip`, `wifi dns`, `wifi ntp`. Debug Builds: `wifi ip ... try <s>` reverts unless kept. On knbg-guests (10.39.39.0/24, gateway .1): Fixed .12 and .13 both reach the internet through 9.9.9.9; a wrong gateway on trial cut the device off and came back by itself; back to DHCP; both NTP servers answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
827 lines
39 KiB
C++
827 lines
39 KiB
C++
// roro9stack firmware entry point: wires Services, Apps and the UI loop together.
|
|
#include <M5Cardputer.h>
|
|
#include <SD.h>
|
|
|
|
|
|
#include <atomic>
|
|
#include <memory>
|
|
|
|
#include "app_manager.h"
|
|
#include "apps/demo_app.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/settings_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 "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 "services/battery_service.h"
|
|
#include "services/irc_service.h"
|
|
#include "services/clock_service.h"
|
|
#include "services/debug_console.h"
|
|
#include "services/gemini_service.h"
|
|
#include "services/gnss_service.h"
|
|
#include "services/power_service.h"
|
|
#include "services/lora_capture_service.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 PowerService* power;
|
|
static ClockService* clockService;
|
|
static GnssService* gnssService;
|
|
static RadioService* radioService;
|
|
static LoraCaptureService* loraCapture;
|
|
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;
|
|
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();
|
|
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);
|
|
|
|
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);
|
|
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);
|
|
services.add(*loraCapture);
|
|
services.add(*storageService);
|
|
services.add(*wifi);
|
|
services.add(*irc);
|
|
services.add(*update);
|
|
#ifdef RORO_DEBUG
|
|
debugConsole = new DebugConsole(*wifi, *storageService);
|
|
services.add(*debugConsole);
|
|
#endif
|
|
|
|
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({"settings", "Settings", false,
|
|
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update})});
|
|
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");
|
|
#ifdef RORO_DEBUG
|
|
debugConsole->setFrame(screen.canvas()); // for `screenshot`
|
|
#endif
|
|
services.startAll(millis());
|
|
apps->begin();
|
|
if (!settings.getBool(Setting::SetupDone)) apps->openModal("setup");
|
|
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 (in a Debug Build) 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);
|
|
#ifdef RORO_DEBUG
|
|
debugConsole = new DebugConsole(*wifi, *storageService);
|
|
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);
|
|
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;
|
|
|
|
static void printListingWhenReady() {
|
|
if (!listingWanted || !storageService->listingReady()) return;
|
|
listingWanted = false;
|
|
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();
|
|
}
|
|
}
|
|
|
|
#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 {
|
|
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);
|
|
}
|
|
#endif
|
|
|
|
static const char* const kHelp =
|
|
"info firmware, uptime, memory, Wi-Fi, app slots\n"
|
|
"tasks FreeRTOS tasks: state, priority, free stack, CPU\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"
|
|
"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] | off | 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 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, or one character\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"
|
|
"ls [folder] | rm <path> | install <path.ota> (Update from SD)\n"
|
|
"sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
|
|
#ifdef RORO_DEBUG
|
|
"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"
|
|
"lora inject <hex> [rssi] [snr] a packet into the LoRa Scanner as if received (nothing is sent)\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\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 == "reboot" || line == "boot other" ||
|
|
line.startsWith("log level ") || line.startsWith("crash") || line.startsWith("coredump") ||
|
|
line == "wifi status" || line.startsWith("wifi add ");
|
|
}
|
|
|
|
static void runCommand(String line) {
|
|
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 == "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");
|
|
system_info::printSlots(console, nvs);
|
|
}
|
|
if (line == "tasks") system_info::printTasks(console);
|
|
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 == "ls" || line.startsWith("ls ") || line.startsWith("rm ")) {
|
|
if (!storageService->state().present) return (void)console.println("sd: no card");
|
|
bool list = !line.startsWith("rm ");
|
|
std::string path = line.length() > 3 ? line.substring(3).c_str() : "/";
|
|
storageService->runJob([list, path]() { // card access stays on the storage task
|
|
if (!list) return (void)console.printf("rm: %s %s\n", path.c_str(), SD.remove(path.c_str()) ? "removed" : "failed");
|
|
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())
|
|
console.printf("%10u %s%s\n", f.isDirectory() ? 0u : (unsigned)f.size(), f.name(), f.isDirectory() ? "/" : "");
|
|
console.printf("ls: end of %s\n", path.c_str());
|
|
});
|
|
}
|
|
if (line.startsWith("install ")) update->installFromSd(line.substring(8).c_str()); // Update from SD
|
|
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.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();
|
|
#ifdef RORO_DEBUG
|
|
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);
|
|
}
|
|
#endif
|
|
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 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");
|
|
#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));
|
|
if (line.startsWith("key ")) { // key up|down|left|right|select|back|home|del|tab
|
|
String k = line.substring(4);
|
|
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 : Key::Select;
|
|
KeyEvent ev = k == "space" ? KeyEvent::character(' ')
|
|
: k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0])
|
|
: KeyEvent::of(key);
|
|
if (!power->onKey(millis())) apps->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");
|
|
storageService->runJob([]() {
|
|
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;
|
|
}
|
|
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(),
|
|
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();
|
|
#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;
|
|
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);
|
|
}
|
|
#endif
|
|
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;
|
|
#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());
|
|
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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
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
|
|
}
|
|
|
|
// 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();
|
|
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);
|
|
}
|
|
|
|
void loop() {
|
|
if (safeMode) return loopSafeMode();
|
|
#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();
|
|
#ifdef RORO_DEBUG
|
|
ipTrialStep();
|
|
#endif
|
|
noteStableOnce(now);
|
|
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) apps->handleKey(e);
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|