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:
166
docs/superpowers/specs/2026-07-22-bfebbx-design.md
Normal file
166
docs/superpowers/specs/2026-07-22-bfebbx-design.md
Normal 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`.
|
||||||
Reference in New Issue
Block a user