diff --git a/docs/superpowers/plans/2026-07-22-bfebbx.md b/docs/superpowers/plans/2026-07-22-bfebbx.md new file mode 100644 index 0000000..4e6ca15 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-bfebbx.md @@ -0,0 +1,1478 @@ +# 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 `__