# 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 `__.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:** `__.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 `__.bbl` using local time. - [ ] **Step 1: Write the failing test** Create `tests/test_naming.cpp`: ```cpp #include "../src/naming.h" #include #include #include 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 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: ```bash 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`: ```cpp #pragma once #include #include 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`: ```cpp #include "naming.h" #include 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(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: ```bash cmake --build build && ctest --test-dir build --output-on-failure ``` Expected: PASS — `1/1 Test #1: naming ... Passed`. - [ ] **Step 6: Commit** ```bash 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 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`: ```cpp #include "../src/betaflight.h" #include #include 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: ```cmake 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: ```bash 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`: ```cpp #pragma once #include #include namespace bfebbx { // Pure: extract craft_name from captured CLI output. std::optional parseCraftName(const std::string& cliOutput); } // namespace bfebbx ``` Create `src/betaflight.cpp`: ```cpp #include "betaflight.h" #include 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 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: ```bash cmake --build build && ctest --test-dir build --output-on-failure ``` Expected: PASS — both `naming` and `betaflight_parse` pass. - [ ] **Step 5: Commit** ```bash 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 bfebbx::listMounts()` — native enumeration of currently mounted filesystems. - `std::vector bfebbx::diffMounts(const std::vector& before, const std::vector& after)` — entries in `after` whose `mountpoint` is absent from `before` (pure). - [ ] **Step 1: Write the failing test** Create `tests/test_mounts.cpp`: ```cpp #include "../src/mounts.h" #include int main() { using namespace bfebbx; std::vector before = { {"/dev/disk1s1", "/"}, {"/dev/disk2s1", "/Volumes/Data"}, }; std::vector 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: ```cmake add_executable(test_mounts tests/test_mounts.cpp) target_link_libraries(test_mounts PRIVATE bfebbx_lib) add_test(NAME mounts COMMAND test_mounts) ``` Run: ```bash cmake -S . -B build && cmake --build build ``` Expected: FAIL — `src/mounts.h` does not exist. - [ ] **Step 3: Write minimal implementation** Create `src/mounts.h`: ```cpp #pragma once #include #include namespace bfebbx { struct MountEntry { std::string device; std::string mountpoint; }; std::vector listMounts(); std::vector diffMounts(const std::vector& before, const std::vector& after); } // namespace bfebbx ``` Create `src/mounts.cpp`: ```cpp #include "mounts.h" #include #if defined(__APPLE__) #include #include #elif defined(__linux__) #include #endif namespace bfebbx { std::vector diffMounts(const std::vector& before, const std::vector& after) { std::set seen; for (const auto& m : before) seen.insert(m.mountpoint); std::vector added; for (const auto& m : after) if (seen.find(m.mountpoint) == seen.end()) added.push_back(m); return added; } #if defined(__APPLE__) std::vector listMounts() { std::vector 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(v)); i += 3; } else { out.push_back(in[i]); } } return out; } std::vector listMounts() { std::vector 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: ```bash 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** ```bash 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. ```cpp #include "../src/serial.h" #include #include #include #include #if defined(__APPLE__) #include #else #include #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: ```cmake 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: ```bash cmake -S . -B build && cmake --build build ``` Expected: FAIL — `src/serial.h` does not exist. - [ ] **Step 3: Write minimal implementation** Create `src/serial.h`: ```cpp #pragma once #include #include 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`: ```cpp #include "serial.h" #include #include #include #include #include #include #include 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(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( deadline - clock::now()) .count(); if (remain <= 0) break; struct pollfd pfd{fd_, POLLIN, 0}; int pr = ::poll(&pfd, 1, static_cast(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(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: ```bash 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** ```bash 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 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. ```cpp #include "../src/betaflight.h" #include "../src/serial.h" #include #include #include #if defined(__APPLE__) #include #else #include #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(std::string(canned).size()); assert(write(slave, canned, static_cast(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`: ```cmake 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: ```bash 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`: ```cpp #pragma once #include #include #include #include "serial.h" namespace bfebbx { std::optional parseCraftName(const std::string& cliOutput); void enterCli(Serial& s); std::optional 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`): ```cpp 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 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: ```bash cmake --build build && ctest --test-dir build --output-on-failure ``` Expected: PASS — `betaflight_proto` extracts `TESTQUAD`. - [ ] **Step 5: Commit** ```bash 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 ` on `EPERM`/`EACCES`. Throws `std::runtime_error` on failure. - [ ] **Step 1: Write the header** Create `src/eject.h`: ```cpp #pragma once #include "mounts.h" namespace bfebbx { void ejectVolume(const MountEntry& vol); } // namespace bfebbx ``` - [ ] **Step 2: Write the implementation** Create `src/eject.cpp`: ```cpp #include "eject.h" #include #include #if defined(__APPLE__) #include #include namespace bfebbx { namespace { struct EjectCtx { bool done = false; bool ok = false; }; void unmountCallback(DADiskRef, DADissenterRef dissenter, void* c) { auto* ctx = static_cast(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(vol.mountpoint.c_str()), static_cast(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 #include #include #include #include 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(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: ```cmake if(APPLE) target_link_libraries(bfebbx_lib PUBLIC "-framework DiskArbitration" "-framework CoreFoundation") endif() ``` Run: ```bash cmake -S . -B build && cmake --build build ``` Expected: PASS — compiles and links cleanly on the current platform. - [ ] **Step 4: Commit** ```bash 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 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`: ```cpp #include "../src/cli.h" #include #include #include #include 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(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(argv)); assert(o.output == "."); assert(o.timeoutSec == 30); assert(!o.verbose); } { const char* argv[] = {"bfebbx", "--bogus"}; bool threw = false; try { parseArgs(2, const_cast(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: ```cmake 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: ```bash 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`: ```cpp #pragma once #include #include 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 scanPorts(const std::string& devDir, const std::string& prefix); const char* defaultPortPrefix(); } // namespace bfebbx ``` Create `src/cli.cpp`: ```cpp #include "cli.h" #include #include #include 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 scanPorts(const std::string& devDir, const std::string& prefix) { std::vector 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: ```bash 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`: ```cpp #include #include #include #include #include #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& 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: ```bash 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** ```bash 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`: ````markdown # 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 `__.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 `__