Files
kyleandClaude Opus 4.8 b6d3eaad82 Implement bfebbx: Betaflight blackbox extractor
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>
2026-07-22 15:13:17 -07:00

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;
}