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>
This commit is contained in:
2026-07-22 15:13:17 -07:00
parent 50050037ee
commit b6d3eaad82
22 changed files with 1068 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
#include "../src/betaflight.h"
#include <cassert>
#include <string>
int main() {
using namespace bfebbx;
assert(parseCraftName("craft_name = VROOM\r\n# ").value() == "VROOM");
assert(parseCraftName("# get craft_name\r\ncraft_name = My Quad\r\n# ").value() == "My Quad");
assert(parseCraftName("craft_name = \r\n").value() == ""); // set but blank
assert(parseCraftName(" craft_name=NoSpaces\r\n").value() == "NoSpaces");
assert(!parseCraftName("set craft_name = X\r\n").has_value()); // 'set ' prefix, not a report line
assert(!parseCraftName("# nothing here\r\n").has_value());
return 0;
}

View File

@@ -0,0 +1,38 @@
#include "../src/betaflight.h"
#include "../src/serial.h"
#include <cassert>
#include <chrono>
#include <termios.h>
#include <unistd.h>
#if defined(__APPLE__)
#include <util.h>
#else
#include <pty.h>
#endif
int main() {
using namespace bfebbx;
using namespace std::chrono_literals;
int master = -1, slave = -1;
assert(openpty(&master, &slave, nullptr, nullptr, nullptr) == 0);
// Set slave to raw mode to disable line-ending translation
struct termios tio;
assert(tcgetattr(slave, &tio) == 0);
cfmakeraw(&tio);
assert(tcsetattr(slave, TCSANOW, &tio) == 0);
// FC echoes the command then reports the value and a prompt.
const char* canned = "# get craft_name\r\ncraft_name = TESTQUAD\r\n# ";
ssize_t len = static_cast<ssize_t>(std::string(canned).size());
assert(write(slave, canned, static_cast<size_t>(len)) == len);
Serial s = Serial::fromFd(master);
auto name = getCraftName(s, 1000ms);
assert(name.has_value());
assert(name.value() == "TESTQUAD");
close(slave);
return 0;
}

73
tests/test_cli.cpp Normal file
View File

@@ -0,0 +1,73 @@
#include "../src/cli.h"
#include <cassert>
#include <cstdio>
#include <fstream>
#include <string>
#include <unistd.h> // mkdtemp
int main() {
using namespace bfebbx;
// parseArgs
{
const char* argv[] = {"bfebbx", "--port", "/dev/ttyACM0",
"--output", "/tmp/out", "--craft-name", "Vroom",
"--timeout", "45", "-v"};
Options o = parseArgs(10, const_cast<char**>(argv));
assert(o.port == "/dev/ttyACM0");
assert(o.output == "/tmp/out");
assert(o.craftName == "Vroom");
assert(o.timeoutSec == 45);
assert(o.verbose);
}
{
const char* argv[] = {"bfebbx"};
Options o = parseArgs(1, const_cast<char**>(argv));
assert(o.output == ".");
assert(o.timeoutSec == 30);
assert(!o.verbose);
}
{
const char* argv[] = {"bfebbx", "--bogus"};
bool threw = false;
try { parseArgs(2, const_cast<char**>(argv)); }
catch (const std::exception&) { threw = true; }
assert(threw);
}
{
const char* argv[] = {"bfebbx", "--timeout", "45abc"};
bool threw = false;
try { parseArgs(3, const_cast<char**>(argv)); }
catch (const std::exception&) { threw = true; }
assert(threw);
}
{
const char* argv[] = {"bfebbx", "--timeout", "-5"};
bool threw = false;
try { parseArgs(3, const_cast<char**>(argv)); }
catch (const std::exception&) { threw = true; }
assert(threw);
}
{
const char* argv[] = {"bfebbx", "--timeout", "45"};
Options o = parseArgs(3, const_cast<char**>(argv));
assert(o.timeoutSec == 45);
}
// scanPorts against a temp directory
{
char tmpl[] = "/tmp/bfebbxXXXXXX";
char* dir = mkdtemp(tmpl);
assert(dir != nullptr);
std::string d = dir;
for (const char* n : {"ttyACM0", "ttyACM1", "ttyS0", "random"}) {
std::ofstream(d + "/" + n).put('x');
}
auto ports = scanPorts(d, "ttyACM");
assert(ports.size() == 2);
assert(ports[0] == d + "/ttyACM0");
assert(ports[1] == d + "/ttyACM1");
}
return 0;
}

29
tests/test_mounts.cpp Normal file
View File

@@ -0,0 +1,29 @@
#include "../src/mounts.h"
#include <cassert>
int main() {
using namespace bfebbx;
std::vector<MountEntry> before = {
{"/dev/disk1s1", "/"},
{"/dev/disk2s1", "/Volumes/Data"},
};
std::vector<MountEntry> after = before;
after.push_back({"/dev/disk5s1", "/Volumes/NO NAME"});
auto added = diffMounts(before, after);
assert(added.size() == 1);
assert(added[0].mountpoint == "/Volumes/NO NAME");
assert(added[0].device == "/dev/disk5s1");
// No change -> empty diff
assert(diffMounts(before, before).empty());
// listMounts should at least return the root filesystem on a live system
bool sawRoot = false;
for (const auto& m : listMounts())
if (m.mountpoint == "/") sawRoot = true;
assert(sawRoot);
return 0;
}

28
tests/test_naming.cpp Normal file
View File

@@ -0,0 +1,28 @@
#include "../src/naming.h"
#include <cassert>
#include <ctime>
#include <string>
int main() {
using namespace bfebbx;
// sanitizeCraftName
assert(sanitizeCraftName("My Quad!") == "My_Quad_");
assert(sanitizeCraftName("vroom-5.inch") == "vroom-5.inch");
assert(sanitizeCraftName("a/b\\c:d") == "a_b_c_d");
assert(sanitizeCraftName("") == "");
// buildFilename — construct a known local time and round-trip it
std::tm tm{};
tm.tm_year = 2026 - 1900;
tm.tm_mon = 6; // July (0-based)
tm.tm_mday = 22;
tm.tm_hour = 14;
tm.tm_min = 30;
tm.tm_sec = 5;
tm.tm_isdst = -1;
std::time_t t = std::mktime(&tm); // interprets tm as local time
assert(buildFilename("vroom", t) == "vroom_20260722_143005.bbl");
return 0;
}

33
tests/test_serial.cpp Normal file
View File

@@ -0,0 +1,33 @@
#include "../src/serial.h"
#include <cassert>
#include <chrono>
#include <string>
#include <unistd.h>
#if defined(__APPLE__)
#include <util.h>
#else
#include <pty.h>
#endif
int main() {
using namespace bfebbx;
using namespace std::chrono_literals;
int master = -1, slave = -1;
assert(openpty(&master, &slave, nullptr, nullptr, nullptr) == 0);
// Pre-load a canned response into the master's read buffer.
const char* resp = "hello world#";
assert(write(slave, resp, 12) == 12);
Serial s = Serial::fromFd(master);
std::string got = s.readUntil("#", 1000ms);
assert(got == "hello world#");
// Timeout path: nothing more to read, returns what it has within the window.
std::string none = s.readUntil("#", 100ms);
assert(none.empty());
close(slave);
return 0;
}