Files
bfebbx/docs/superpowers/plans/2026-07-22-bfebbx.md
Kyle Isom 50050037ee Add bfebbx implementation plan
Eight TDD tasks: naming + scaffold, craft_name parse, mount
enumeration/diff, serial termios wrapper, betaflight protocol, eject,
CLI orchestration, README/integration test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:01:05 -07:00

43 KiB

bfebbx Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a cross-platform (macOS + Linux) C++ CLI that extracts a Betaflight FC's blackbox log to <craft_name>_<YYYYMMDD>_<HHMMSS>.bbl in one command.

Architecture: A single binary drives the full chain: auto-detect serial port → read craft_name over the Betaflight CLI → send msc to reboot into USB mass storage → detect the newly-mounted volume natively → copy btfl_all.bbl → eject. Hardware-touching code (serial termios, native mount enumeration, eject) is isolated behind thin functions; the logic (name sanitizing/formatting, craft_name parsing, mount diffing, port scanning, arg parsing) is pure and unit-tested.

Tech Stack: C++17, CMake, ctest. No third-party libraries. POSIX termios for serial, getmntinfo(3)//proc/mounts for mount enumeration, std::filesystem::copy_file for the copy, DiskArbitration.framework (macOS) / umount(2)+udisksctl fallback (Linux) for eject.

Global Constraints

  • Language/standard: C++17. Compile with -Wall -Wextra.
  • Platforms: macOS and Linux only. Platform-specific code is #ifdef-guarded (__APPLE__ / __linux__).
  • No third-party libraries. System frameworks only: macOS links DiskArbitration and CoreFoundation.
  • Minimize subprocesses: the only permitted subprocess in the whole tool is the Linux eject fallback (udisksctl unmount) when umount(2) returns EPERM/EACCES.
  • Filename format: <craft_name>_<YYYYMMDD>_<HHMMSS>.bbl, timestamp = local wall-clock at extraction time.
  • Name sanitizing: characters outside [A-Za-z0-9._-]_. Empty craft_name and no --craft-name → fallback betaflight.
  • Source file to copy: btfl_all.bbl, and only that.
  • Serial: 115200 baud, 8N1, raw mode.
  • Refuse to overwrite an existing destination file.
  • Namespace: all library code lives in namespace bfebbx.

Task 1: Project scaffold + naming module

Establishes the CMake build and ctest wiring (folded in here because the first testable deliverable needs them), plus the pure filename-building logic.

Files:

  • Create: CMakeLists.txt
  • Create: src/naming.h
  • Create: src/naming.cpp
  • Create: tests/test_naming.cpp

Interfaces:

  • Consumes: nothing.

  • Produces:

    • std::string bfebbx::sanitizeCraftName(const std::string& raw) — maps each char outside [A-Za-z0-9._-] to _; empty input returns empty string.
    • std::string bfebbx::buildFilename(const std::string& craftName, std::time_t when) — returns <craftName>_<YYYYMMDD>_<HHMMSS>.bbl using local time.
  • Step 1: Write the failing test

Create tests/test_naming.cpp:

#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;
}
  • Step 2: Create the CMake scaffold

Create CMakeLists.txt:

cmake_minimum_required(VERSION 3.16)
project(bfebbx CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_compile_options(-Wall -Wextra)

enable_testing()

# --- library sources (grows as tasks are added) ---
add_library(bfebbx_lib
    src/naming.cpp
)
target_include_directories(bfebbx_lib PUBLIC src)

# --- tests ---
add_executable(test_naming tests/test_naming.cpp)
target_link_libraries(test_naming PRIVATE bfebbx_lib)
add_test(NAME naming COMMAND test_naming)
  • Step 3: Run test to verify it fails

Run:

cmake -S . -B build && cmake --build build

Expected: FAIL — src/naming.h / src/naming.cpp do not exist, build errors.

  • Step 4: Write minimal implementation

Create src/naming.h:

#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

Create src/naming.cpp:

#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
  • Step 5: Run tests to verify they pass

Run:

cmake --build build && ctest --test-dir build --output-on-failure

Expected: PASS — 1/1 Test #1: naming ... Passed.

  • Step 6: Commit
git add CMakeLists.txt src/naming.h src/naming.cpp tests/test_naming.cpp
git commit -m "feat: naming module + cmake/ctest scaffold"

Task 2: Betaflight craft_name parsing (pure)

Files:

  • Create: src/betaflight.h
  • Create: src/betaflight.cpp
  • Create: tests/test_betaflight_parse.cpp
  • Modify: CMakeLists.txt (add src/betaflight.cpp to the lib; add the test)

Interfaces:

  • Consumes: nothing.

  • Produces:

    • std::optional<std::string> bfebbx::parseCraftName(const std::string& cliOutput) — scans lines for one that (after left-trim) begins with craft_name, returns the trimmed value after =. Returns an empty string when the value is blank; returns std::nullopt when no matching line exists.
  • Step 1: Write the failing test

Create tests/test_betaflight_parse.cpp:

#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;
}
  • Step 2: Run test to verify it fails

Add to CMakeLists.txt: append src/betaflight.cpp to the add_library(bfebbx_lib ...) source list, and add below the naming test:

add_executable(test_betaflight_parse tests/test_betaflight_parse.cpp)
target_link_libraries(test_betaflight_parse PRIVATE bfebbx_lib)
add_test(NAME betaflight_parse COMMAND test_betaflight_parse)

Run:

cmake -S . -B build && cmake --build build

Expected: FAIL — src/betaflight.h / parseCraftName do not exist.

  • Step 3: Write minimal implementation

Create src/betaflight.h:

#pragma once
#include <optional>
#include <string>

namespace bfebbx {

// Pure: extract craft_name from captured CLI output.
std::optional<std::string> parseCraftName(const std::string& cliOutput);

}  // namespace bfebbx

Create src/betaflight.cpp:

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

}  // namespace bfebbx
  • Step 4: Run tests to verify they pass

Run:

cmake --build build && ctest --test-dir build --output-on-failure

Expected: PASS — both naming and betaflight_parse pass.

  • Step 5: Commit
git add src/betaflight.h src/betaflight.cpp tests/test_betaflight_parse.cpp CMakeLists.txt
git commit -m "feat: craft_name CLI output parsing"

Task 3: Mount enumeration + diff

Files:

  • Create: src/mounts.h
  • Create: src/mounts.cpp
  • Create: tests/test_mounts.cpp
  • Modify: CMakeLists.txt (add src/mounts.cpp; add the test)

Interfaces:

  • Consumes: nothing.

  • Produces:

    • struct bfebbx::MountEntry { std::string device; std::string mountpoint; };
    • std::vector<MountEntry> bfebbx::listMounts() — native enumeration of currently mounted filesystems.
    • std::vector<MountEntry> bfebbx::diffMounts(const std::vector<MountEntry>& before, const std::vector<MountEntry>& after) — entries in after whose mountpoint is absent from before (pure).
  • Step 1: Write the failing test

Create tests/test_mounts.cpp:

#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;
}
  • Step 2: Run test to verify it fails

Add to CMakeLists.txt: append src/mounts.cpp to the lib sources, and:

add_executable(test_mounts tests/test_mounts.cpp)
target_link_libraries(test_mounts PRIVATE bfebbx_lib)
add_test(NAME mounts COMMAND test_mounts)

Run:

cmake -S . -B build && cmake --build build

Expected: FAIL — src/mounts.h does not exist.

  • Step 3: Write minimal implementation

Create src/mounts.h:

#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

Create src/mounts.cpp:

#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
  • Step 4: Run tests to verify they pass

Run:

cmake --build build && ctest --test-dir build --output-on-failure

Expected: PASS — mounts test passes (root filesystem is found; diff logic correct).

  • Step 5: Commit
git add src/mounts.h src/mounts.cpp tests/test_mounts.cpp CMakeLists.txt
git commit -m "feat: native mount enumeration and diff"

Task 4: Serial port (termios) wrapper

Files:

  • Create: src/serial.h
  • Create: src/serial.cpp
  • Create: tests/test_serial.cpp
  • Modify: CMakeLists.txt (add src/serial.cpp; add the test — links util on Linux for openpty)

Interfaces:

  • Consumes: nothing.

  • Produces:

    • class bfebbx::Serial with:
      • static Serial openPort(const std::string& path, int baud) — opens+configures a real serial device (throws std::runtime_error on failure).
      • static Serial fromFd(int fd) — wraps an already-open fd (used by tests via a pty; takes ownership).
      • void writeAll(const std::string& data) — writes all bytes (throws on error).
      • std::string readUntil(const std::string& delim, std::chrono::milliseconds timeout) — accumulates bytes until delim appears in the buffer or the timeout elapses; returns whatever was read.
      • destructor closes the fd; movable, non-copyable.
  • Step 1: Write the failing test

Create tests/test_serial.cpp. It uses a pseudo-terminal pair: writing to the slave makes bytes readable on the master, which Serial wraps.

#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;
}
  • Step 2: Run test to verify it fails

Add to CMakeLists.txt: append src/serial.cpp to the lib sources, and:

add_executable(test_serial tests/test_serial.cpp)
target_link_libraries(test_serial PRIVATE bfebbx_lib)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
    target_link_libraries(test_serial PRIVATE util)  # openpty
endif()
add_test(NAME serial COMMAND test_serial)

Run:

cmake -S . -B build && cmake --build build

Expected: FAIL — src/serial.h does not exist.

  • Step 3: Write minimal implementation

Create src/serial.h:

#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);

    int fd() const { return fd_; }

private:
    explicit Serial(int fd) : fd_(fd) {}
    int fd_ = -1;
};

}  // namespace bfebbx

Create src/serial.cpp:

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

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);
    cfsetispeed(&tio, sp);
    cfsetospeed(&tio, sp);
    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 || errno == EAGAIN) continue;
            throw std::runtime_error(std::string("write: ") + std::strerror(errno));
        }
        off += static_cast<size_t>(n);
    }
}

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
  • Step 4: Run tests to verify they pass

Run:

cmake --build build && ctest --test-dir build --output-on-failure

Expected: PASS — serial test passes (canned response read via pty; timeout returns empty).

  • Step 5: Commit
git add src/serial.h src/serial.cpp tests/test_serial.cpp CMakeLists.txt
git commit -m "feat: termios serial wrapper with pty-tested readUntil"

Task 5: Betaflight serial protocol (getCraftName / enterCli / enterMsc)

Files:

  • Modify: src/betaflight.h (add serial-driven declarations)
  • Modify: src/betaflight.cpp (add implementations)
  • Create: tests/test_betaflight_proto.cpp
  • Modify: CMakeLists.txt (add the test — links util on Linux)

Interfaces:

  • Consumes: bfebbx::Serial (Task 4), bfebbx::parseCraftName (Task 2).

  • Produces:

    • void bfebbx::enterCli(Serial& s) — sends #, drains the CLI banner up to the # prompt (short timeout).
    • std::optional<std::string> bfebbx::getCraftName(Serial& s, std::chrono::milliseconds timeout) — sends get craft_name\r\n, reads until the # prompt or timeout, returns parseCraftName of the captured output.
    • void bfebbx::enterMsc(Serial& s) — sends msc\r\n (fire-and-forget; the FC reboots).
  • Step 1: Write the failing test

Create tests/test_betaflight_proto.cpp. A pty carries a canned FC response; getCraftName should extract the value.

#include "../src/betaflight.h"
#include "../src/serial.h"
#include <cassert>
#include <chrono>
#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);

    // 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;
}
  • Step 2: Run test to verify it fails

Add to CMakeLists.txt:

add_executable(test_betaflight_proto tests/test_betaflight_proto.cpp)
target_link_libraries(test_betaflight_proto PRIVATE bfebbx_lib)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
    target_link_libraries(test_betaflight_proto PRIVATE util)
endif()
add_test(NAME betaflight_proto COMMAND test_betaflight_proto)

Run:

cmake -S . -B build && cmake --build build

Expected: FAIL — getCraftName is not declared.

  • Step 3: Write minimal implementation

Modify src/betaflight.h — add includes and declarations inside namespace bfebbx:

#pragma once
#include <chrono>
#include <optional>
#include <string>

#include "serial.h"

namespace bfebbx {

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

Modify src/betaflight.cpp — add at the end of the namespace (keep the existing trim and parseCraftName):

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");
}
  • Step 4: Run tests to verify they pass

Run:

cmake --build build && ctest --test-dir build --output-on-failure

Expected: PASS — betaflight_proto extracts TESTQUAD.

  • Step 5: Commit
git add src/betaflight.h src/betaflight.cpp tests/test_betaflight_proto.cpp CMakeLists.txt
git commit -m "feat: betaflight serial protocol (cli/craft_name/msc)"

Task 6: Eject module

Hardware-facing; no unit test (verified in manual integration testing, Task 8). Small, self-contained deliverable.

Files:

  • Create: src/eject.h
  • Create: src/eject.cpp
  • Modify: CMakeLists.txt (add src/eject.cpp; link DiskArbitration/CoreFoundation on macOS)

Interfaces:

  • Consumes: bfebbx::MountEntry (Task 3).

  • Produces:

    • void bfebbx::ejectVolume(const MountEntry& vol) — unmounts the volume. macOS: DiskArbitration DADiskUnmount. Linux: umount(2), falling back to udisksctl unmount -b <device> on EPERM/EACCES. Throws std::runtime_error on failure.
  • Step 1: Write the header

Create src/eject.h:

#pragma once
#include "mounts.h"

namespace bfebbx {

void ejectVolume(const MountEntry& vol);

}  // namespace bfebbx
  • Step 2: Write the implementation

Create src/eject.cpp:

#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);
        CFRunLoopRun();  // stopped by unmountCallback
        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.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;
    if (waitpid(pid, &status, 0) < 0) 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
  • Step 3: Wire up CMake and build

In CMakeLists.txt, append src/eject.cpp to the lib sources, and after the add_library(bfebbx_lib ...) block add:

if(APPLE)
    target_link_libraries(bfebbx_lib PUBLIC
        "-framework DiskArbitration"
        "-framework CoreFoundation")
endif()

Run:

cmake -S . -B build && cmake --build build

Expected: PASS — compiles and links cleanly on the current platform.

  • Step 4: Commit
git add src/eject.h src/eject.cpp CMakeLists.txt
git commit -m "feat: eject module (DiskArbitration / umount+udisksctl)"

Task 7: CLI — arg parsing, port scan, orchestration

Files:

  • Create: src/cli.h
  • Create: src/cli.cpp (pure helpers: parseArgs, scanPorts)
  • Create: src/main.cpp (orchestration; not the lib)
  • Create: tests/test_cli.cpp
  • Modify: CMakeLists.txt (add src/cli.cpp to lib; add bfebbx executable; add the test)

Interfaces:

  • Consumes: everything above (Serial, betaflight, mounts, eject, naming).

  • Produces:

    • struct bfebbx::Options { std::string port; std::string output = "."; std::string craftName; int timeoutSec = 30; bool verbose = false; bool help = false; };
    • Options bfebbx::parseArgs(int argc, char** argv) — throws std::runtime_error on unknown flag / missing value.
    • std::vector<std::string> bfebbx::scanPorts(const std::string& devDir, const std::string& prefix) — sorted full paths of entries in devDir starting with prefix (pure; testable against a temp dir).
    • const char* bfebbx::defaultPortPrefix()"tty.usbmodem" on macOS, "ttyACM" on Linux.
  • Step 1: Write the failing test

Create tests/test_cli.cpp:

#include "../src/cli.h"
#include <cassert>
#include <cstdio>
#include <fstream>
#include <string>

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

    // 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;
}
  • Step 2: Run test to verify it fails

Add to CMakeLists.txt: append src/cli.cpp to the lib sources, then:

add_executable(bfebbx src/main.cpp)
target_link_libraries(bfebbx PRIVATE bfebbx_lib)

add_executable(test_cli tests/test_cli.cpp)
target_link_libraries(test_cli PRIVATE bfebbx_lib)
add_test(NAME cli COMMAND test_cli)

Run:

cmake -S . -B build && cmake --build build

Expected: FAIL — src/cli.h and src/main.cpp do not exist.

  • Step 3: Write the pure helpers

Create src/cli.h:

#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

Create src/cli.cpp:

#include "cli.h"

#include <algorithm>
#include <filesystem>
#include <stdexcept>

namespace fs = std::filesystem;

namespace bfebbx {

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 = std::stoi(need(i, "--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
  • Step 4: Run the cli test to verify it passes

Run:

cmake --build build && ctest --test-dir build -R cli --output-on-failure

Expected: PASS — cli test passes.

  • Step 5: Write the orchestration (main.cpp)

Create src/main.cpp:

#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);
        // 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";
        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;
}

Note: Serial::fromFd(-1) is used to release the original serial fd (its destructor guards on fd_ >= 0, so a -1 handle is inert). This drops the port cleanly before polling for the volume.

  • Step 6: Build everything and run the full test suite

Run:

cmake --build build && ctest --test-dir build --output-on-failure

Expected: PASS — all tests (naming, betaflight_parse, mounts, serial, betaflight_proto, cli) pass and the bfebbx binary builds.

  • Step 7: Commit
git add src/cli.h src/cli.cpp src/main.cpp tests/test_cli.cpp CMakeLists.txt
git commit -m "feat: CLI arg parsing, port scan, and full orchestration"

Task 8: README + manual integration test procedure

Files:

  • Create: README.md

Interfaces:

  • Consumes: the finished bfebbx binary.

  • Produces: user + operator documentation, including the hardware integration test steps (serial handshake and eject can only be verified against a real FC).

  • Step 1: Write the README

Create README.md:

# bfebbx — Betaflight Extract Blackbox

Extract a Betaflight flight controller's blackbox log to a timestamped,
craft-named file in a single command. macOS and Linux.

## What it does

1. Auto-detects the FC's serial port.
2. Reads `craft_name` over the Betaflight CLI.
3. Sends `msc` to reboot the FC into USB mass-storage mode.
4. Waits for the storage volume to mount, then copies `btfl_all.bbl` to
   `<craft_name>_<YYYYMMDD>_<HHMMSS>.bbl`.
5. Ejects the volume.

## Build

```bash
cmake -S . -B build
cmake --build build
ctest --test-dir build            # run unit tests
```

The binary is `build/bfebbx`.

Requirements: a C++17 compiler and CMake ≥ 3.16. No third-party libraries.
macOS links the system DiskArbitration/CoreFoundation frameworks.

## Usage

```
bfebbx [--port PORT] [--output DIR] [--craft-name NAME] [--timeout SEC] [-v]
```

| Flag | Default | Meaning |
|------|---------|---------|
| `--port` | auto-detect | Serial device (e.g. `/dev/tty.usbmodem1101`). |
| `--output` | current dir | Directory for the extracted `.bbl`. |
| `--craft-name` | read from FC | Override / fallback craft name. |
| `--timeout` | 30 | Seconds to wait for the storage volume. |
| `-v` | off | Verbose progress on stderr. |

### Exit codes

| Code | Meaning |
|------|---------|
| 0 | Success |
| 2 | No candidate serial port |
| 3 | Multiple candidate ports (pass `--port`) |
| 4 | Serial open / handshake failure |
| 5 | Storage volume did not appear before timeout |
| 6 | `btfl_all.bbl` not found on the volume |
| 7 | Copy failed |
| 8 | Destination file already exists |
| 64 | Usage error |

## Manual integration test (requires a real FC)

The pure logic is unit-tested. The serial handshake and eject need hardware:

1. Flash/confirm a Betaflight FC with blackbox logs on onboard flash.
2. Connect it via USB. Confirm the port appears: `ls /dev/tty.usbmodem*`
   (macOS) or `ls /dev/ttyACM*` (Linux).
3. Run `./build/bfebbx -v` in an empty directory.
4. Verify:
   - The craft name is read and printed.
   - The FC re-enumerates as a USB drive.
   - A file `<craft_name>_<date>_<time>.bbl` is written and matches the size
     of `btfl_all.bbl` on the drive.
   - The volume is ejected (no longer in `/Volumes` on macOS / `/proc/mounts`
     on Linux).
5. Re-run in the same directory within the same second is not required; the
   timestamp differs each run, so collisions are not expected. To exercise the
   overwrite guard, `--craft-name X` twice within one second and confirm exit
   code 8.

## Design

See `docs/superpowers/specs/2026-07-22-bfebbx-design.md`.
  • Step 2: Commit
git add README.md
git commit -m "docs: README with usage and manual integration test"

Notes on scope carried from the spec

  • Port VID/PID filtering is described as "ideally" in the spec. This plan scans by device-name prefix (tty.usbmodem / ttyACM) to stay dependency-free (VID/PID filtering needs IOKit/libudev). Ambiguity is handled by erroring and asking for --port, which satisfies the spec's stated behavior. True VID/PID filtering is future work.
  • Interactive port prompt is explicitly out of scope for this version (spec: multiple candidates → error).