Cross-platform (macOS + Linux) C++17 CLI that extracts a Betaflight FC's blackbox log in one command: auto-detect the serial port, read craft_name over the Betaflight CLI, send `msc` to reboot into USB mass storage, wait for the volume to mount, copy btfl_all.bbl to <craft_name>_<YYYYMMDD>_<HHMMSS>.bbl, and eject. Native throughout (termios serial, getmntinfo/proc-mounts mount detection, DiskArbitration/umount eject); the only subprocess is the Linux udisksctl eject fallback. Includes CMake/ctest build, unit tests for the pure logic (naming, craft_name parsing, mount diff, serial via pty, CLI parsing), README with a manual hardware integration procedure, and design/plan docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
158 lines
5.1 KiB
C++
158 lines
5.1 KiB
C++
#include <chrono>
|
|
#include <cstdio>
|
|
#include <filesystem>
|
|
#include <iostream>
|
|
#include <thread>
|
|
|
|
#include "betaflight.h"
|
|
#include "cli.h"
|
|
#include "eject.h"
|
|
#include "mounts.h"
|
|
#include "naming.h"
|
|
#include "serial.h"
|
|
|
|
namespace fs = std::filesystem;
|
|
using namespace bfebbx;
|
|
|
|
namespace {
|
|
|
|
const char* kUsage =
|
|
"usage: bfebbx [--port PORT] [--output DIR] [--craft-name NAME] "
|
|
"[--timeout SEC] [-v]\n";
|
|
|
|
// Exit codes (documented in README).
|
|
enum Exit {
|
|
OK = 0,
|
|
NO_PORT = 2,
|
|
AMBIGUOUS_PORT = 3,
|
|
SERIAL_FAIL = 4,
|
|
VOLUME_TIMEOUT = 5,
|
|
FILE_MISSING = 6,
|
|
COPY_FAIL = 7,
|
|
DEST_EXISTS = 8,
|
|
USAGE = 64,
|
|
};
|
|
|
|
std::string resolvePort(const Options& opt) {
|
|
if (!opt.port.empty()) return opt.port;
|
|
auto ports = scanPorts("/dev", defaultPortPrefix());
|
|
if (ports.empty()) {
|
|
std::cerr << "error: no Betaflight serial port found (looked for /dev/"
|
|
<< defaultPortPrefix() << "*). Use --port.\n";
|
|
std::exit(NO_PORT);
|
|
}
|
|
if (ports.size() > 1) {
|
|
std::cerr << "error: multiple candidate ports; pass --port:\n";
|
|
for (const auto& p : ports) std::cerr << " " << p << "\n";
|
|
std::exit(AMBIGUOUS_PORT);
|
|
}
|
|
return ports.front();
|
|
}
|
|
|
|
// Poll for a newly-mounted volume that contains btfl_all.bbl.
|
|
MountEntry waitForVolume(const std::vector<MountEntry>& before, int timeoutSec,
|
|
bool verbose) {
|
|
using clock = std::chrono::steady_clock;
|
|
auto deadline = clock::now() + std::chrono::seconds(timeoutSec);
|
|
while (clock::now() < deadline) {
|
|
auto added = diffMounts(before, listMounts());
|
|
for (const auto& m : added) {
|
|
fs::path candidate = fs::path(m.mountpoint) / "btfl_all.bbl";
|
|
std::error_code ec;
|
|
if (fs::exists(candidate, ec)) {
|
|
if (verbose) std::cerr << "found volume: " << m.mountpoint << "\n";
|
|
return m;
|
|
}
|
|
}
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
|
}
|
|
std::cerr << "error: mass-storage volume did not appear within " << timeoutSec
|
|
<< "s (try --timeout).\n";
|
|
std::exit(VOLUME_TIMEOUT);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv) {
|
|
Options opt;
|
|
try {
|
|
opt = parseArgs(argc, argv);
|
|
} catch (const std::exception& e) {
|
|
std::cerr << "error: " << e.what() << "\n" << kUsage;
|
|
return USAGE;
|
|
}
|
|
if (opt.help) {
|
|
std::cout << kUsage;
|
|
return OK;
|
|
}
|
|
|
|
const std::string port = resolvePort(opt);
|
|
if (opt.verbose) std::cerr << "port: " << port << "\n";
|
|
|
|
// 1. Read craft_name over the CLI (before msc reboots the board).
|
|
std::string craft = opt.craftName;
|
|
try {
|
|
Serial s = Serial::openPort(port, 115200);
|
|
enterCli(s);
|
|
auto name = getCraftName(s, std::chrono::milliseconds(2000));
|
|
if (craft.empty() && name.has_value()) craft = name.value();
|
|
|
|
// 2. Snapshot mounts, then trigger mass-storage mode.
|
|
auto before = listMounts();
|
|
if (opt.verbose) std::cerr << "entering mass-storage mode...\n";
|
|
enterMsc(s);
|
|
// Ensure the msc reboot trigger is actually transmitted before we
|
|
// release the fd, since a non-blocking fd offers no such guarantee.
|
|
s.drain();
|
|
// Serial port goes away as the FC reboots; drop it before polling.
|
|
s = Serial::fromFd(-1);
|
|
|
|
// 3. Wait for the volume.
|
|
MountEntry vol = waitForVolume(before, opt.timeoutSec, opt.verbose);
|
|
|
|
// 4. Build destination name and copy.
|
|
std::string sanitized = sanitizeCraftName(craft);
|
|
if (sanitized.empty()) {
|
|
sanitized = "betaflight";
|
|
if (craft.empty()) {
|
|
std::cerr << "warning: could not read craft_name; using '" << sanitized
|
|
<< "'\n";
|
|
}
|
|
}
|
|
std::string fname = buildFilename(sanitized, std::time(nullptr));
|
|
fs::path dest = fs::path(opt.output) / fname;
|
|
|
|
std::error_code ec;
|
|
if (fs::exists(dest, ec)) {
|
|
std::cerr << "error: destination already exists: " << dest << "\n";
|
|
return DEST_EXISTS;
|
|
}
|
|
fs::path src = fs::path(vol.mountpoint) / "btfl_all.bbl";
|
|
if (!fs::exists(src, ec)) {
|
|
std::cerr << "error: btfl_all.bbl not found on " << vol.mountpoint
|
|
<< " (leaving volume mounted).\n";
|
|
return FILE_MISSING;
|
|
}
|
|
fs::copy_file(src, dest, ec);
|
|
if (ec) {
|
|
std::cerr << "error: copy failed: " << ec.message()
|
|
<< " (leaving volume mounted).\n";
|
|
return COPY_FAIL;
|
|
}
|
|
std::cout << "wrote " << dest << "\n";
|
|
|
|
// 5. Eject on success.
|
|
try {
|
|
ejectVolume(vol);
|
|
if (opt.verbose) std::cerr << "ejected " << vol.mountpoint << "\n";
|
|
} catch (const std::exception& e) {
|
|
std::cerr << "warning: eject failed: " << e.what()
|
|
<< " (file already saved; unmount manually).\n";
|
|
}
|
|
} catch (const std::exception& e) {
|
|
std::cerr << "error: " << e.what() << "\n";
|
|
return SERIAL_FAIL;
|
|
}
|
|
return OK;
|
|
}
|