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