Implement bfebbx: Betaflight blackbox extractor
Cross-platform (macOS + Linux) C++17 CLI that extracts a Betaflight FC's blackbox log in one command: auto-detect the serial port, read craft_name over the Betaflight CLI, send `msc` to reboot into USB mass storage, wait for the volume to mount, copy btfl_all.bbl to <craft_name>_<YYYYMMDD>_<HHMMSS>.bbl, and eject. Native throughout (termios serial, getmntinfo/proc-mounts mount detection, DiskArbitration/umount eject); the only subprocess is the Linux udisksctl eject fallback. Includes CMake/ctest build, unit tests for the pure logic (naming, craft_name parsing, mount diff, serial via pty, CLI parsing), README with a manual hardware integration procedure, and design/plan docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Build output
|
||||
/build/
|
||||
|
||||
# SDD scratch (ledger, briefs, reports, review packages)
|
||||
/.superpowers/
|
||||
61
CMakeLists.txt
Normal file
61
CMakeLists.txt
Normal file
@@ -0,0 +1,61 @@
|
||||
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
|
||||
src/betaflight.cpp
|
||||
src/mounts.cpp
|
||||
src/serial.cpp
|
||||
src/eject.cpp
|
||||
src/cli.cpp
|
||||
)
|
||||
target_include_directories(bfebbx_lib PUBLIC src)
|
||||
|
||||
# --- platform-specific linking ---
|
||||
if(APPLE)
|
||||
target_link_libraries(bfebbx_lib PUBLIC
|
||||
"-framework DiskArbitration"
|
||||
"-framework CoreFoundation")
|
||||
endif()
|
||||
|
||||
# --- tests ---
|
||||
add_executable(test_naming tests/test_naming.cpp)
|
||||
target_link_libraries(test_naming PRIVATE bfebbx_lib)
|
||||
add_test(NAME naming COMMAND test_naming)
|
||||
|
||||
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)
|
||||
|
||||
add_executable(test_mounts tests/test_mounts.cpp)
|
||||
target_link_libraries(test_mounts PRIVATE bfebbx_lib)
|
||||
add_test(NAME mounts COMMAND test_mounts)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
# --- main executable ---
|
||||
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)
|
||||
78
README.md
Normal file
78
README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 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
|
||||
`<craft_name>_<YYYYMMDD>_<HHMMSS>.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 `<craft_name>_<date>_<time>.bbl` is written and matches the size
|
||||
of `btfl_all.bbl` on the drive.
|
||||
- The volume is ejected (no longer in `/Volumes` on macOS / `/proc/mounts`
|
||||
on Linux).
|
||||
5. Re-run in the same directory within the same second is not required; the
|
||||
timestamp differs each run, so collisions are not expected. To exercise the
|
||||
overwrite guard, `--craft-name X` twice within one second and confirm exit
|
||||
code 8.
|
||||
|
||||
## Design
|
||||
|
||||
See `docs/superpowers/specs/2026-07-22-bfebbx-design.md`.
|
||||
50
src/betaflight.cpp
Normal file
50
src/betaflight.cpp
Normal file
@@ -0,0 +1,50 @@
|
||||
#include "betaflight.h"
|
||||
|
||||
#include <sstream>
|
||||
|
||||
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<std::string> 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;
|
||||
}
|
||||
|
||||
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<std::string> 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");
|
||||
}
|
||||
|
||||
} // namespace bfebbx
|
||||
17
src/betaflight.h
Normal file
17
src/betaflight.h
Normal file
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "serial.h"
|
||||
|
||||
namespace bfebbx {
|
||||
|
||||
// Pure: extract craft_name from captured CLI output.
|
||||
std::optional<std::string> parseCraftName(const std::string& cliOutput);
|
||||
|
||||
void enterCli(Serial& s);
|
||||
std::optional<std::string> getCraftName(Serial& s, std::chrono::milliseconds timeout);
|
||||
void enterMsc(Serial& s);
|
||||
|
||||
} // namespace bfebbx
|
||||
71
src/cli.cpp
Normal file
71
src/cli.cpp
Normal file
@@ -0,0 +1,71 @@
|
||||
#include "cli.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace bfebbx {
|
||||
|
||||
namespace {
|
||||
|
||||
// Strictly parse a non-negative integer: the entire token must be consumed
|
||||
// and the value must not be negative. Unlike std::stoi alone, this rejects
|
||||
// trailing garbage ("45abc") and negative values ("-5").
|
||||
int parseNonNegativeInt(const std::string& value, const char* flag) {
|
||||
if (value.empty())
|
||||
throw std::runtime_error(std::string("invalid ") + flag + ": " + value);
|
||||
size_t pos = 0;
|
||||
int result;
|
||||
try {
|
||||
result = std::stoi(value, &pos);
|
||||
} catch (const std::exception&) {
|
||||
throw std::runtime_error(std::string("invalid ") + flag + ": " + value);
|
||||
}
|
||||
if (pos != value.size() || result < 0)
|
||||
throw std::runtime_error(std::string("invalid ") + flag + ": " + value);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
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 = parseNonNegativeInt(need(i, "--timeout"), "--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<std::string> scanPorts(const std::string& devDir, const std::string& prefix) {
|
||||
std::vector<std::string> 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
|
||||
20
src/cli.h
Normal file
20
src/cli.h
Normal file
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::string> scanPorts(const std::string& devDir, const std::string& prefix);
|
||||
const char* defaultPortPrefix();
|
||||
|
||||
} // namespace bfebbx
|
||||
107
src/eject.cpp
Normal file
107
src/eject.cpp
Normal file
@@ -0,0 +1,107 @@
|
||||
#include "eject.h"
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#if defined(__APPLE__)
|
||||
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
#include <DiskArbitration/DiskArbitration.h>
|
||||
|
||||
namespace bfebbx {
|
||||
|
||||
namespace {
|
||||
struct EjectCtx {
|
||||
bool done = false;
|
||||
bool ok = false;
|
||||
};
|
||||
|
||||
void unmountCallback(DADiskRef, DADissenterRef dissenter, void* c) {
|
||||
auto* ctx = static_cast<EjectCtx*>(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<const UInt8*>(vol.mountpoint.c_str()),
|
||||
static_cast<CFIndex>(vol.mountpoint.size()), true);
|
||||
DADiskRef disk = url ? DADiskCreateFromVolumePath(kCFAllocatorDefault, session, url)
|
||||
: nullptr;
|
||||
|
||||
EjectCtx ctx;
|
||||
if (disk) {
|
||||
DADiskUnmount(disk, kDADiskUnmountOptionWhole, unmountCallback, &ctx);
|
||||
// Bound the wait: DiskArbitration's callback normally always fires, but a
|
||||
// daemon crash or invalidated disk could otherwise hang the process forever.
|
||||
const CFTimeInterval kUnmountTimeout = 15.0; // seconds, total
|
||||
while (!ctx.done) {
|
||||
CFRunLoopRunResult r =
|
||||
CFRunLoopRunInMode(kCFRunLoopDefaultMode, kUnmountTimeout, true);
|
||||
if (r == kCFRunLoopRunTimedOut || r == kCFRunLoopRunFinished) break;
|
||||
// kCFRunLoopRunStopped (callback called CFRunLoopStop) or
|
||||
// kCFRunLoopRunHandledSource: re-check ctx.done and continue.
|
||||
}
|
||||
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.done) throw std::runtime_error("unmount timed out for " + vol.mountpoint);
|
||||
if (!ctx.ok) throw std::runtime_error("unmount denied for " + vol.mountpoint);
|
||||
}
|
||||
|
||||
} // namespace bfebbx
|
||||
|
||||
#elif defined(__linux__)
|
||||
|
||||
#include <sys/mount.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
|
||||
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<char* const*>(argv));
|
||||
_exit(127);
|
||||
}
|
||||
int status = 0;
|
||||
while (waitpid(pid, &status, 0) < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
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
|
||||
8
src/eject.h
Normal file
8
src/eject.h
Normal file
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
#include "mounts.h"
|
||||
|
||||
namespace bfebbx {
|
||||
|
||||
void ejectVolume(const MountEntry& vol);
|
||||
|
||||
} // namespace bfebbx
|
||||
157
src/main.cpp
Normal file
157
src/main.cpp
Normal file
@@ -0,0 +1,157 @@
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
|
||||
#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<MountEntry>& 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);
|
||||
// Ensure the msc reboot trigger is actually transmitted before we
|
||||
// release the fd, since a non-blocking fd offers no such guarantee.
|
||||
s.drain();
|
||||
// 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";
|
||||
if (craft.empty()) {
|
||||
std::cerr << "warning: could not read craft_name; using '" << sanitized
|
||||
<< "'\n";
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
66
src/mounts.cpp
Normal file
66
src/mounts.cpp
Normal file
@@ -0,0 +1,66 @@
|
||||
#include "mounts.h"
|
||||
|
||||
#include <set>
|
||||
|
||||
#if defined(__APPLE__)
|
||||
#include <sys/mount.h>
|
||||
#include <sys/param.h>
|
||||
#elif defined(__linux__)
|
||||
#include <cstdio>
|
||||
#endif
|
||||
|
||||
namespace bfebbx {
|
||||
|
||||
std::vector<MountEntry> diffMounts(const std::vector<MountEntry>& before,
|
||||
const std::vector<MountEntry>& after) {
|
||||
std::set<std::string> seen;
|
||||
for (const auto& m : before) seen.insert(m.mountpoint);
|
||||
std::vector<MountEntry> added;
|
||||
for (const auto& m : after)
|
||||
if (seen.find(m.mountpoint) == seen.end()) added.push_back(m);
|
||||
return added;
|
||||
}
|
||||
|
||||
#if defined(__APPLE__)
|
||||
|
||||
std::vector<MountEntry> listMounts() {
|
||||
std::vector<MountEntry> 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<char>(v));
|
||||
i += 3;
|
||||
} else {
|
||||
out.push_back(in[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<MountEntry> listMounts() {
|
||||
std::vector<MountEntry> 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
|
||||
16
src/mounts.h
Normal file
16
src/mounts.h
Normal file
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace bfebbx {
|
||||
|
||||
struct MountEntry {
|
||||
std::string device;
|
||||
std::string mountpoint;
|
||||
};
|
||||
|
||||
std::vector<MountEntry> listMounts();
|
||||
std::vector<MountEntry> diffMounts(const std::vector<MountEntry>& before,
|
||||
const std::vector<MountEntry>& after);
|
||||
|
||||
} // namespace bfebbx
|
||||
25
src/naming.cpp
Normal file
25
src/naming.cpp
Normal file
@@ -0,0 +1,25 @@
|
||||
#include "naming.h"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
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<char>(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
|
||||
10
src/naming.h
Normal file
10
src/naming.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
|
||||
namespace bfebbx {
|
||||
|
||||
std::string sanitizeCraftName(const std::string& raw);
|
||||
std::string buildFilename(const std::string& craftName, std::time_t when);
|
||||
|
||||
} // namespace bfebbx
|
||||
131
src/serial.cpp
Normal file
131
src/serial.cpp
Normal file
@@ -0,0 +1,131 @@
|
||||
#include "serial.h"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <stdexcept>
|
||||
#include <termios.h>
|
||||
#include <unistd.h>
|
||||
|
||||
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 B0; // Sentinel for unsupported baud
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (sp == B0) {
|
||||
::close(fd);
|
||||
throw std::runtime_error("unsupported baud: " + std::to_string(baud));
|
||||
}
|
||||
if (cfsetispeed(&tio, sp) != 0) {
|
||||
::close(fd);
|
||||
throw std::runtime_error("cfsetispeed " + path + ": " + std::strerror(errno));
|
||||
}
|
||||
if (cfsetospeed(&tio, sp) != 0) {
|
||||
::close(fd);
|
||||
throw std::runtime_error("cfsetospeed " + path + ": " + std::strerror(errno));
|
||||
}
|
||||
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) continue;
|
||||
if (errno == EAGAIN) {
|
||||
// Wait for fd to become writable before retrying
|
||||
struct pollfd pfd{fd_, POLLOUT, 0};
|
||||
int pr = ::poll(&pfd, 1, -1);
|
||||
if (pr < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
throw std::runtime_error(std::string("poll: ") + std::strerror(errno));
|
||||
}
|
||||
continue; // Retry write now that fd is writable
|
||||
}
|
||||
throw std::runtime_error(std::string("write: ") + std::strerror(errno));
|
||||
}
|
||||
off += static_cast<size_t>(n);
|
||||
}
|
||||
}
|
||||
|
||||
void Serial::drain() {
|
||||
if (fd_ >= 0) tcdrain(fd_);
|
||||
}
|
||||
|
||||
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<std::chrono::milliseconds>(
|
||||
deadline - clock::now())
|
||||
.count();
|
||||
if (remain <= 0) break;
|
||||
struct pollfd pfd{fd_, POLLIN, 0};
|
||||
int pr = ::poll(&pfd, 1, static_cast<int>(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<size_t>(n));
|
||||
} else if (n == 0) {
|
||||
break; // EOF
|
||||
} else if (errno != EINTR && errno != EAGAIN) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
} // namespace bfebbx
|
||||
29
src/serial.h
Normal file
29
src/serial.h
Normal file
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
|
||||
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);
|
||||
void drain();
|
||||
|
||||
int fd() const { return fd_; }
|
||||
|
||||
private:
|
||||
explicit Serial(int fd) : fd_(fd) {}
|
||||
int fd_ = -1;
|
||||
};
|
||||
|
||||
} // namespace bfebbx
|
||||
16
tests/test_betaflight_parse.cpp
Normal file
16
tests/test_betaflight_parse.cpp
Normal file
@@ -0,0 +1,16 @@
|
||||
#include "../src/betaflight.h"
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
|
||||
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;
|
||||
}
|
||||
38
tests/test_betaflight_proto.cpp
Normal file
38
tests/test_betaflight_proto.cpp
Normal file
@@ -0,0 +1,38 @@
|
||||
#include "../src/betaflight.h"
|
||||
#include "../src/serial.h"
|
||||
#include <cassert>
|
||||
#include <chrono>
|
||||
#include <termios.h>
|
||||
#include <unistd.h>
|
||||
#if defined(__APPLE__)
|
||||
#include <util.h>
|
||||
#else
|
||||
#include <pty.h>
|
||||
#endif
|
||||
|
||||
int main() {
|
||||
using namespace bfebbx;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
int master = -1, slave = -1;
|
||||
assert(openpty(&master, &slave, nullptr, nullptr, nullptr) == 0);
|
||||
|
||||
// Set slave to raw mode to disable line-ending translation
|
||||
struct termios tio;
|
||||
assert(tcgetattr(slave, &tio) == 0);
|
||||
cfmakeraw(&tio);
|
||||
assert(tcsetattr(slave, TCSANOW, &tio) == 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<ssize_t>(std::string(canned).size());
|
||||
assert(write(slave, canned, static_cast<size_t>(len)) == len);
|
||||
|
||||
Serial s = Serial::fromFd(master);
|
||||
auto name = getCraftName(s, 1000ms);
|
||||
assert(name.has_value());
|
||||
assert(name.value() == "TESTQUAD");
|
||||
|
||||
close(slave);
|
||||
return 0;
|
||||
}
|
||||
73
tests/test_cli.cpp
Normal file
73
tests/test_cli.cpp
Normal file
@@ -0,0 +1,73 @@
|
||||
#include "../src/cli.h"
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <unistd.h> // mkdtemp
|
||||
|
||||
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<char**>(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<char**>(argv));
|
||||
assert(o.output == ".");
|
||||
assert(o.timeoutSec == 30);
|
||||
assert(!o.verbose);
|
||||
}
|
||||
{
|
||||
const char* argv[] = {"bfebbx", "--bogus"};
|
||||
bool threw = false;
|
||||
try { parseArgs(2, const_cast<char**>(argv)); }
|
||||
catch (const std::exception&) { threw = true; }
|
||||
assert(threw);
|
||||
}
|
||||
{
|
||||
const char* argv[] = {"bfebbx", "--timeout", "45abc"};
|
||||
bool threw = false;
|
||||
try { parseArgs(3, const_cast<char**>(argv)); }
|
||||
catch (const std::exception&) { threw = true; }
|
||||
assert(threw);
|
||||
}
|
||||
{
|
||||
const char* argv[] = {"bfebbx", "--timeout", "-5"};
|
||||
bool threw = false;
|
||||
try { parseArgs(3, const_cast<char**>(argv)); }
|
||||
catch (const std::exception&) { threw = true; }
|
||||
assert(threw);
|
||||
}
|
||||
{
|
||||
const char* argv[] = {"bfebbx", "--timeout", "45"};
|
||||
Options o = parseArgs(3, const_cast<char**>(argv));
|
||||
assert(o.timeoutSec == 45);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
29
tests/test_mounts.cpp
Normal file
29
tests/test_mounts.cpp
Normal file
@@ -0,0 +1,29 @@
|
||||
#include "../src/mounts.h"
|
||||
#include <cassert>
|
||||
|
||||
int main() {
|
||||
using namespace bfebbx;
|
||||
|
||||
std::vector<MountEntry> before = {
|
||||
{"/dev/disk1s1", "/"},
|
||||
{"/dev/disk2s1", "/Volumes/Data"},
|
||||
};
|
||||
std::vector<MountEntry> 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;
|
||||
}
|
||||
28
tests/test_naming.cpp
Normal file
28
tests/test_naming.cpp
Normal file
@@ -0,0 +1,28 @@
|
||||
#include "../src/naming.h"
|
||||
#include <cassert>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
|
||||
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;
|
||||
}
|
||||
33
tests/test_serial.cpp
Normal file
33
tests/test_serial.cpp
Normal file
@@ -0,0 +1,33 @@
|
||||
#include "../src/serial.h"
|
||||
#include <cassert>
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
#include <unistd.h>
|
||||
#if defined(__APPLE__)
|
||||
#include <util.h>
|
||||
#else
|
||||
#include <pty.h>
|
||||
#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;
|
||||
}
|
||||
Reference in New Issue
Block a user