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
co-authored by Claude Opus 4.8
parent 50050037ee
commit b6d3eaad82
22 changed files with 1068 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
#include "betaflight.h"
#include <sstream>
namespace bfebbx {
static std::string trim(const std::string& s) {
size_t b = s.find_first_not_of(" \t");
if (b == std::string::npos) return "";
size_t e = s.find_last_not_of(" \t");
return s.substr(b, e - b + 1);
}
std::optional<std::string> parseCraftName(const std::string& cliOutput) {
std::istringstream ss(cliOutput);
std::string line;
const std::string key = "craft_name";
while (std::getline(ss, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
size_t s = line.find_first_not_of(" \t");
if (s == std::string::npos) continue;
std::string t = line.substr(s);
if (t.rfind(key, 0) != 0) continue; // must start with craft_name
char after = t.size() > key.size() ? t[key.size()] : '\0';
if (after != ' ' && after != '\t' && after != '=') continue; // exact key
size_t eq = t.find('=');
if (eq == std::string::npos) continue;
return trim(t.substr(eq + 1));
}
return std::nullopt;
}
void enterCli(Serial& s) {
s.writeAll("#");
// Drain the "Entering CLI Mode" banner up to the prompt; best-effort.
s.readUntil("# ", std::chrono::milliseconds(1500));
}
std::optional<std::string> getCraftName(Serial& s, std::chrono::milliseconds timeout) {
s.writeAll("get craft_name\r\n");
std::string out = s.readUntil("# ", timeout);
return parseCraftName(out);
}
void enterMsc(Serial& s) {
// The FC reboots into mass-storage mode; no meaningful reply to wait for.
s.writeAll("msc\r\n");
}
} // namespace bfebbx
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <chrono>
#include <optional>
#include <string>
#include "serial.h"
namespace bfebbx {
// Pure: extract craft_name from captured CLI output.
std::optional<std::string> parseCraftName(const std::string& cliOutput);
void enterCli(Serial& s);
std::optional<std::string> getCraftName(Serial& s, std::chrono::milliseconds timeout);
void enterMsc(Serial& s);
} // namespace bfebbx
+71
View File
@@ -0,0 +1,71 @@
#include "cli.h"
#include <algorithm>
#include <filesystem>
#include <stdexcept>
namespace fs = std::filesystem;
namespace bfebbx {
namespace {
// Strictly parse a non-negative integer: the entire token must be consumed
// and the value must not be negative. Unlike std::stoi alone, this rejects
// trailing garbage ("45abc") and negative values ("-5").
int parseNonNegativeInt(const std::string& value, const char* flag) {
if (value.empty())
throw std::runtime_error(std::string("invalid ") + flag + ": " + value);
size_t pos = 0;
int result;
try {
result = std::stoi(value, &pos);
} catch (const std::exception&) {
throw std::runtime_error(std::string("invalid ") + flag + ": " + value);
}
if (pos != value.size() || result < 0)
throw std::runtime_error(std::string("invalid ") + flag + ": " + value);
return result;
}
} // namespace
const char* defaultPortPrefix() {
#if defined(__APPLE__)
return "tty.usbmodem";
#else
return "ttyACM";
#endif
}
Options parseArgs(int argc, char** argv) {
Options o;
auto need = [&](int& i, const char* flag) -> std::string {
if (i + 1 >= argc) throw std::runtime_error(std::string("missing value for ") + flag);
return argv[++i];
};
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
if (a == "--port") o.port = need(i, "--port");
else if (a == "--output") o.output = need(i, "--output");
else if (a == "--craft-name") o.craftName = need(i, "--craft-name");
else if (a == "--timeout") o.timeoutSec = parseNonNegativeInt(need(i, "--timeout"), "--timeout");
else if (a == "-v" || a == "--verbose") o.verbose = true;
else if (a == "-h" || a == "--help") o.help = true;
else throw std::runtime_error("unknown argument: " + a);
}
return o;
}
std::vector<std::string> scanPorts(const std::string& devDir, const std::string& prefix) {
std::vector<std::string> out;
std::error_code ec;
for (const auto& e : fs::directory_iterator(devDir, ec)) {
std::string name = e.path().filename().string();
if (name.rfind(prefix, 0) == 0) out.push_back(e.path().string());
}
std::sort(out.begin(), out.end());
return out;
}
} // namespace bfebbx
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <string>
#include <vector>
namespace bfebbx {
struct Options {
std::string port;
std::string output = ".";
std::string craftName;
int timeoutSec = 30;
bool verbose = false;
bool help = false;
};
Options parseArgs(int argc, char** argv);
std::vector<std::string> scanPorts(const std::string& devDir, const std::string& prefix);
const char* defaultPortPrefix();
} // namespace bfebbx
+107
View File
@@ -0,0 +1,107 @@
#include "eject.h"
#include <stdexcept>
#include <string>
#if defined(__APPLE__)
#include <CoreFoundation/CoreFoundation.h>
#include <DiskArbitration/DiskArbitration.h>
namespace bfebbx {
namespace {
struct EjectCtx {
bool done = false;
bool ok = false;
};
void unmountCallback(DADiskRef, DADissenterRef dissenter, void* c) {
auto* ctx = static_cast<EjectCtx*>(c);
ctx->ok = (dissenter == nullptr);
ctx->done = true;
CFRunLoopStop(CFRunLoopGetCurrent());
}
} // namespace
void ejectVolume(const MountEntry& vol) {
DASessionRef session = DASessionCreate(kCFAllocatorDefault);
if (!session) throw std::runtime_error("DASessionCreate failed");
DASessionScheduleWithRunLoop(session, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
CFURLRef url = CFURLCreateFromFileSystemRepresentation(
kCFAllocatorDefault,
reinterpret_cast<const UInt8*>(vol.mountpoint.c_str()),
static_cast<CFIndex>(vol.mountpoint.size()), true);
DADiskRef disk = url ? DADiskCreateFromVolumePath(kCFAllocatorDefault, session, url)
: nullptr;
EjectCtx ctx;
if (disk) {
DADiskUnmount(disk, kDADiskUnmountOptionWhole, unmountCallback, &ctx);
// Bound the wait: DiskArbitration's callback normally always fires, but a
// daemon crash or invalidated disk could otherwise hang the process forever.
const CFTimeInterval kUnmountTimeout = 15.0; // seconds, total
while (!ctx.done) {
CFRunLoopRunResult r =
CFRunLoopRunInMode(kCFRunLoopDefaultMode, kUnmountTimeout, true);
if (r == kCFRunLoopRunTimedOut || r == kCFRunLoopRunFinished) break;
// kCFRunLoopRunStopped (callback called CFRunLoopStop) or
// kCFRunLoopRunHandledSource: re-check ctx.done and continue.
}
CFRelease(disk);
}
if (url) CFRelease(url);
DASessionUnscheduleFromRunLoop(session, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
CFRelease(session);
if (!disk) throw std::runtime_error("could not resolve disk for " + vol.mountpoint);
if (!ctx.done) throw std::runtime_error("unmount timed out for " + vol.mountpoint);
if (!ctx.ok) throw std::runtime_error("unmount denied for " + vol.mountpoint);
}
} // namespace bfebbx
#elif defined(__linux__)
#include <sys/mount.h>
#include <sys/wait.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
namespace bfebbx {
namespace {
// Run a command with no shell; returns the exit status (or -1 on spawn failure).
int runNoShell(const char* const argv[]) {
pid_t pid = fork();
if (pid < 0) return -1;
if (pid == 0) {
execvp(argv[0], const_cast<char* const*>(argv));
_exit(127);
}
int status = 0;
while (waitpid(pid, &status, 0) < 0) {
if (errno == EINTR) continue;
return -1;
}
return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
}
} // namespace
void ejectVolume(const MountEntry& vol) {
if (umount(vol.mountpoint.c_str()) == 0) return;
if (errno == EPERM || errno == EACCES) {
const char* argv[] = {"udisksctl", "unmount", "-b", vol.device.c_str(), nullptr};
if (runNoShell(argv) == 0) return;
throw std::runtime_error("umount denied and udisksctl fallback failed for " +
vol.mountpoint);
}
throw std::runtime_error("umount " + vol.mountpoint + ": " + std::strerror(errno));
}
} // namespace bfebbx
#endif
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include "mounts.h"
namespace bfebbx {
void ejectVolume(const MountEntry& vol);
} // namespace bfebbx
+157
View File
@@ -0,0 +1,157 @@
#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;
}
+66
View File
@@ -0,0 +1,66 @@
#include "mounts.h"
#include <set>
#if defined(__APPLE__)
#include <sys/mount.h>
#include <sys/param.h>
#elif defined(__linux__)
#include <cstdio>
#endif
namespace bfebbx {
std::vector<MountEntry> diffMounts(const std::vector<MountEntry>& before,
const std::vector<MountEntry>& after) {
std::set<std::string> seen;
for (const auto& m : before) seen.insert(m.mountpoint);
std::vector<MountEntry> added;
for (const auto& m : after)
if (seen.find(m.mountpoint) == seen.end()) added.push_back(m);
return added;
}
#if defined(__APPLE__)
std::vector<MountEntry> listMounts() {
std::vector<MountEntry> out;
struct statfs* buf = nullptr;
int n = getmntinfo(&buf, MNT_NOWAIT);
for (int i = 0; i < n; ++i)
out.push_back({buf[i].f_mntfromname, buf[i].f_mntonname});
return out;
}
#elif defined(__linux__)
// Unescape the octal \NNN sequences used in /proc/mounts fields.
static std::string unescape(const std::string& in) {
std::string out;
for (size_t i = 0; i < in.size(); ++i) {
if (in[i] == '\\' && i + 3 < in.size()) {
int v = (in[i + 1] - '0') * 64 + (in[i + 2] - '0') * 8 + (in[i + 3] - '0');
out.push_back(static_cast<char>(v));
i += 3;
} else {
out.push_back(in[i]);
}
}
return out;
}
std::vector<MountEntry> listMounts() {
std::vector<MountEntry> out;
std::FILE* f = std::fopen("/proc/mounts", "re");
if (!f) return out;
char dev[4096], mnt[4096];
// fields: device mountpoint fstype options dump pass
while (std::fscanf(f, "%4095s %4095s %*s %*s %*d %*d\n", dev, mnt) == 2)
out.push_back({unescape(dev), unescape(mnt)});
std::fclose(f);
return out;
}
#endif
} // namespace bfebbx
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <string>
#include <vector>
namespace bfebbx {
struct MountEntry {
std::string device;
std::string mountpoint;
};
std::vector<MountEntry> listMounts();
std::vector<MountEntry> diffMounts(const std::vector<MountEntry>& before,
const std::vector<MountEntry>& after);
} // namespace bfebbx
+25
View File
@@ -0,0 +1,25 @@
#include "naming.h"
#include <cctype>
namespace bfebbx {
std::string sanitizeCraftName(const std::string& raw) {
std::string out;
out.reserve(raw.size());
for (unsigned char c : raw) {
bool ok = std::isalnum(c) || c == '.' || c == '_' || c == '-';
out.push_back(ok ? static_cast<char>(c) : '_');
}
return out;
}
std::string buildFilename(const std::string& craftName, std::time_t when) {
std::tm tm{};
localtime_r(&when, &tm);
char buf[32];
std::strftime(buf, sizeof(buf), "%Y%m%d_%H%M%S", &tm);
return craftName + "_" + buf + ".bbl";
}
} // namespace bfebbx
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <ctime>
#include <string>
namespace bfebbx {
std::string sanitizeCraftName(const std::string& raw);
std::string buildFilename(const std::string& craftName, std::time_t when);
} // namespace bfebbx
+131
View File
@@ -0,0 +1,131 @@
#include "serial.h"
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <poll.h>
#include <stdexcept>
#include <termios.h>
#include <unistd.h>
namespace bfebbx {
static speed_t toSpeed(int baud) {
switch (baud) {
case 9600: return B9600;
case 19200: return B19200;
case 38400: return B38400;
case 57600: return B57600;
case 115200: return B115200;
default: return B0; // Sentinel for unsupported baud
}
}
Serial Serial::openPort(const std::string& path, int baud) {
int fd = ::open(path.c_str(), O_RDWR | O_NOCTTY | O_NONBLOCK);
if (fd < 0)
throw std::runtime_error("open " + path + ": " + std::strerror(errno));
struct termios tio{};
if (tcgetattr(fd, &tio) != 0) {
::close(fd);
throw std::runtime_error("tcgetattr " + path + ": " + std::strerror(errno));
}
cfmakeraw(&tio);
tio.c_cflag |= (CLOCAL | CREAD);
tio.c_cc[VMIN] = 0;
tio.c_cc[VTIME] = 0;
speed_t sp = toSpeed(baud);
if (sp == B0) {
::close(fd);
throw std::runtime_error("unsupported baud: " + std::to_string(baud));
}
if (cfsetispeed(&tio, sp) != 0) {
::close(fd);
throw std::runtime_error("cfsetispeed " + path + ": " + std::strerror(errno));
}
if (cfsetospeed(&tio, sp) != 0) {
::close(fd);
throw std::runtime_error("cfsetospeed " + path + ": " + std::strerror(errno));
}
if (tcsetattr(fd, TCSANOW, &tio) != 0) {
::close(fd);
throw std::runtime_error("tcsetattr " + path + ": " + std::strerror(errno));
}
return Serial(fd);
}
Serial Serial::fromFd(int fd) { return Serial(fd); }
Serial::Serial(Serial&& other) noexcept : fd_(other.fd_) { other.fd_ = -1; }
Serial& Serial::operator=(Serial&& other) noexcept {
if (this != &other) {
if (fd_ >= 0) ::close(fd_);
fd_ = other.fd_;
other.fd_ = -1;
}
return *this;
}
Serial::~Serial() {
if (fd_ >= 0) ::close(fd_);
}
void Serial::writeAll(const std::string& data) {
size_t off = 0;
while (off < data.size()) {
ssize_t n = ::write(fd_, data.data() + off, data.size() - off);
if (n < 0) {
if (errno == EINTR) continue;
if (errno == EAGAIN) {
// Wait for fd to become writable before retrying
struct pollfd pfd{fd_, POLLOUT, 0};
int pr = ::poll(&pfd, 1, -1);
if (pr < 0) {
if (errno == EINTR) continue;
throw std::runtime_error(std::string("poll: ") + std::strerror(errno));
}
continue; // Retry write now that fd is writable
}
throw std::runtime_error(std::string("write: ") + std::strerror(errno));
}
off += static_cast<size_t>(n);
}
}
void Serial::drain() {
if (fd_ >= 0) tcdrain(fd_);
}
std::string Serial::readUntil(const std::string& delim,
std::chrono::milliseconds timeout) {
using clock = std::chrono::steady_clock;
auto deadline = clock::now() + timeout;
std::string acc;
char buf[256];
while (true) {
if (!delim.empty() && acc.find(delim) != std::string::npos) break;
auto remain = std::chrono::duration_cast<std::chrono::milliseconds>(
deadline - clock::now())
.count();
if (remain <= 0) break;
struct pollfd pfd{fd_, POLLIN, 0};
int pr = ::poll(&pfd, 1, static_cast<int>(remain));
if (pr <= 0) {
if (pr < 0 && errno == EINTR) continue;
break; // timeout or error
}
ssize_t n = ::read(fd_, buf, sizeof(buf));
if (n > 0) {
acc.append(buf, static_cast<size_t>(n));
} else if (n == 0) {
break; // EOF
} else if (errno != EINTR && errno != EAGAIN) {
break;
}
}
return acc;
}
} // namespace bfebbx
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <chrono>
#include <string>
namespace bfebbx {
class Serial {
public:
static Serial openPort(const std::string& path, int baud);
static Serial fromFd(int fd);
Serial(Serial&& other) noexcept;
Serial& operator=(Serial&& other) noexcept;
Serial(const Serial&) = delete;
Serial& operator=(const Serial&) = delete;
~Serial();
void writeAll(const std::string& data);
std::string readUntil(const std::string& delim, std::chrono::milliseconds timeout);
void drain();
int fd() const { return fd_; }
private:
explicit Serial(int fd) : fd_(fd) {}
int fd_ = -1;
};
} // namespace bfebbx