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:
16
tests/test_betaflight_parse.cpp
Normal file
16
tests/test_betaflight_parse.cpp
Normal 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;
|
||||
}
|
||||
38
tests/test_betaflight_proto.cpp
Normal file
38
tests/test_betaflight_proto.cpp
Normal 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
73
tests/test_cli.cpp
Normal 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
29
tests/test_mounts.cpp
Normal 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
28
tests/test_naming.cpp
Normal 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
33
tests/test_serial.cpp
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user