Add bfebbx design doc

Betaflight Extract Blackbox: cross-platform C++ CLI that detects the
FC serial port, reads craft_name, reboots into MSC mode, and copies
btfl_all.bbl to <craft_name>_<date>_<time>.bbl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 01:41:10 -07:00
commit be169d1c2a

View File

@@ -0,0 +1,166 @@
# bfebbx — Betaflight Extract Blackbox — Design
**Date:** 2026-07-22
**Status:** Approved for planning
## Overview
`bfebbx` is a cross-platform (macOS + Linux) C++ command-line tool that extracts
the blackbox log from a Betaflight flight controller connected over USB. In a
single command it detects the FC's serial port, reads the craft name, reboots the
FC into USB mass-storage mode, waits for the storage volume to mount, copies
`btfl_all.bbl` to a timestamped, craft-named file, and ejects the volume.
Target filename: `<craft_name>_<YYYYMMDD>_<HHMMSS>.bbl`.
## Goals
- One command performs the full mount-and-copy chain.
- Minimize subprocess use: everything is native syscalls/frameworks except a
single Linux eject fallback.
- No third-party libraries; system frameworks only.
- Work on both macOS and Linux behind a thin platform seam.
## Non-Goals
- Erasing the FC dataflash after download.
- Reading/using the FC real-time clock.
- Downloading anything other than `btfl_all.bbl`.
- A GUI or any interactive configuration beyond a possible port prompt (not in
this version — ambiguous ports error out).
## CLI
```
bfebbx [--port PORT] [--output DIR] [--craft-name NAME] [--timeout SEC] [-v]
```
| Flag | Default | Meaning |
|------|---------|---------|
| `--port PORT` | auto-detect | Serial device to use. Overrides auto-detection. |
| `--output DIR` | current working directory | Directory the `.bbl` is written to. |
| `--craft-name NAME` | read from FC | Override the craft name used in the filename. Also the fallback if the FC's craft_name is unset. |
| `--timeout SEC` | 30 | Max seconds to wait for the mass-storage volume to appear. |
| `-v` | off | Verbose logging of each step. |
## Runtime Flow
1. **Find port.** Scan `/dev/tty.usbmodem*` (macOS) / `/dev/ttyACM*` (Linux),
ideally filtered by Betaflight's USB VID/PID. Exactly one candidate → use it.
Zero or multiple → error listing candidates and suggest `--port`. `--port`
bypasses the scan.
2. **Read craft_name.** Open serial at 115200 8N1. Enter Betaflight CLI (send
`#`, wait for the CLI banner/prompt). Send `get craft_name`, parse the
`craft_name = X` line. This happens *before* msc because msc reboots the FC.
If unset/empty and no `--craft-name`, fall back to `betaflight`.
3. **Snapshot mounts.** Record the current set of mounted volumes (native; see
below) so the new volume can be identified by difference.
4. **Enter MSC.** Send `msc`. The FC reboots and re-enumerates as a USB
mass-storage device; the serial port disappears.
5. **Wait for volume.** Poll (up to `--timeout`) for a *new* mounted volume
(present in the after-set, absent from the before-set) that actually contains
`btfl_all.bbl`. macOS auto-mounts via `diskarbitrationd`; Linux relies on the
desktop automounter. Identifying by "new volume containing the file" avoids
guessing the FAT volume label.
6. **Copy + rename.** `std::filesystem::copy_file` the `btfl_all.bbl` to
`<output>/<craft_name>_<YYYYMMDD>_<HHMMSS>.bbl`. Timestamp is local wall-clock
at extraction time. Refuse to overwrite an existing destination file.
7. **Eject** the volume on success. On failure (copy error, file missing), leave
it mounted and report that, so the user can inspect.
## Minimizing Subprocesses
| Operation | macOS | Linux |
|-----------|-------|-------|
| Serial I/O | termios (native) | termios (native) |
| Mount enumeration | `getmntinfo(3)``struct statfs[]` | read `/proc/self/mountinfo` |
| Mounting new volume | auto (`diskarbitrationd`) | auto (desktop automounter) |
| File copy | `std::filesystem::copy_file` | `std::filesystem::copy_file` |
| Eject / unmount | DiskArbitration.framework (`DADiskUnmount`) | `umount(2)`; on `EPERM` fall back to shelling `udisksctl unmount` |
The **only** subprocess in the entire tool is the Linux eject fallback, and only
when the unprivileged `umount(2)` is denied.
## Naming Rules
- `sanitizeCraftName`: characters outside `[A-Za-z0-9._-]` are replaced with `_`.
- Empty craft_name and no `--craft-name``betaflight`.
- `buildFilename(name, time_t)``<name>_<YYYYMMDD>_<HHMMSS>.bbl` using local time.
## Betaflight Serial Handshake
- Baud 115200, 8N1 (nominal over USB VCP, set regardless).
- Enter CLI: send `#`; the FC replies with a CLI banner and a `# ` prompt.
- Read craft name: send `get craft_name\r\n`; parse the response line matching
`^craft_name\s*=\s*(.*)$`.
- Enter MSC: send `msc\r\n`; the FC prints a restart notice and reboots into
mass-storage mode.
**Technical bets to verify against real firmware during implementation:** the
exact CLI-entry handshake and that `msc` is the reboot-to-mass-storage command.
Both are standard Betaflight, but firmware/board variation exists — hence the
generous timeout and the "volume actually contains the file" check rather than
trusting labels.
## Code Structure
```
bfebbx/
├── CMakeLists.txt
├── README.md
├── src/
│ ├── main.cpp # arg parsing, orchestration, exit codes
│ ├── serial.{h,cpp} # termios open/config, timed read, readUntil/readLine
│ ├── betaflight.{h,cpp} # enterCli / getCraftName / enterMsc + pure parse fn
│ ├── mounts.{h,cpp} # MountEntry, listMounts() [getmntinfo | mountinfo], diff (pure)
│ ├── eject.{h,cpp} # macOS DiskArbitration; Linux umount(2) + udisksctl fallback
│ └── naming.{h,cpp} # sanitizeCraftName(), buildFilename() — pure
└── tests/
├── test_naming.cpp
├── test_betaflight_parse.cpp
└── test_mounts.cpp
```
**Design intent:** hardware-touching code (serial, eject, live mount listing) is
isolated behind thin functions; the *logic* — craft_name parsing, filename
building, mount before/after diffing — is pure and independently testable.
- **Build:** C++17, CMake. `-Wall -Wextra`. macOS links `DiskArbitration` and
`CoreFoundation` frameworks; no third-party libraries on either platform.
- **Platform seam:** `#ifdef`-guarded implementations inside `mounts.cpp` and
`eject.cpp`; `serial.cpp` is shared POSIX termios.
## Testing
Matching the "no frameworks, stdlib only" philosophy, adapted to C++: small
assert-based test executables registered with `add_test` and run via `ctest`.
| Test | Covers |
|------|--------|
| `test_naming` | `sanitizeCraftName` edge cases; `buildFilename` format |
| `test_betaflight_parse` | craft_name extraction from sample CLI output (set, empty, whitespace, surrounding noise) |
| `test_mounts` | `diffMounts(before, after)` returns the correct new entries |
Serial handshake and eject require hardware and are verified by manual
integration testing against a real FC, documented in the README.
## Error Handling & Exit Codes
`0` on success; distinct non-zero codes with a clear stderr message per class:
| Class | Behavior |
|-------|----------|
| No candidate port | Error, suggest `--port`. |
| Multiple candidate ports | Error listing them, suggest `--port`. |
| Serial open / CLI handshake failure | Error; suggest checking the connection/firmware. |
| craft_name unreadable | Warn, fall back to `--craft-name` or `betaflight`, continue. |
| Volume never appears within timeout | Error; suggest increasing `--timeout`. |
| `btfl_all.bbl` not found on volume | Error; note what the volume contained. Leave mounted. |
| Copy failure | Error. Leave volume mounted for inspection. |
| Destination file exists | Error; refuse to overwrite. |
## Future Work (out of scope)
- Optional dataflash erase after successful download.
- Interactive port selection when multiple candidates are found.
- Downloading individual per-flight logs in addition to `btfl_all.bbl`.