Add a one-shot test command and a tool that edits the config files safely

This commit is contained in:
pgdalmeida 2026-09-23 18:52:44 +02:00
parent 4049a85b9b
commit 86f930a671
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
12 changed files with 753 additions and 8 deletions

View File

@ -59,6 +59,7 @@ endif()
# ---------------------------------------------------------------------------
add_library(fgc_core STATIC
src/core/Config.cpp
src/core/IniEdit.cpp
src/core/Paths.cpp
src/core/SingleInstance.cpp
src/core/Logger.cpp
@ -176,6 +177,14 @@ else()
message(STATUS "WITH_TUI=OFF: building without the terminal dashboard (headless console only)")
endif()
# ---------------------------------------------------------------------------
# fgc-config: writes and edits config.ini / hardware.ini, validating every
# result through the same loader as the program. Core-only, so it builds
# wherever fgc_core does; the tower installer depends on it.
# ---------------------------------------------------------------------------
add_executable(fgc-config tools/fgc_config.cpp)
target_link_libraries(fgc-config PRIVATE fgc_core)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------

View File

@ -388,8 +388,13 @@ No new RPxxxx device found yet, waiting.. (x20, then it gives up)
| `picotool reboot -f -u` | refused: no reset interface | - |
| MicroPython REPL | **works** | n/a |
So drop into MicroPython's own REPL and tell it to enter the bootloader. Write
this to a file and run it with `~/.platformio/penv/bin/python` (it has pyserial):
So drop into MicroPython's own REPL and tell it to enter the bootloader.
`firmware/tools/flash.sh` (which `deploy.sh` runs on the tower) now does this by
itself when the port's by-id name says `MicroPython_Board`: `./deploy.sh` with
`SERIAL_PORT` set to that path should flash a factory board directly. `[untested]`
- no factory board has been through that path yet; the manual way, verified on
tower 121, is below. Write this to a file and run it with
`~/.platformio/penv/bin/python` (it has pyserial):
```python
import serial, time
@ -904,6 +909,22 @@ table and lets the tower move while you work out what `hardware.ini` is missing.
## 11. Verification checklist
The checks that move nothing can be run as one command, which is what a script
should use rather than typing into the dashboard. Stop the app first: it holds
the serial port.
```bash
scripts/fgc stop
GENICAM_GENTL64_PATH=/opt/VimbaX/cti ./build/fire_gimbal_control --no-mqtt --test hardware
echo $? # 0 PASS, 4 FAIL, 5 could not start (the log says why)
scripts/fgc start
```
It waits for the firmware to be talking and configured before it looks, so a
freshly flashed board is not failed for values the host had not sent yet.
`build/fgc-config check --config config.ini --hardware /etc/fire_gimbal_control/hardware.ini`
checks that the two files load together, and changes nothing.
| Check | How | Tower 121 |
|---|---|---|
| Key-only ssh | `ssh ggs@<tower> hostname`, no prompt | ✅ |

View File

@ -28,8 +28,19 @@ struct RuntimeOptions {
std::string log_level; // empty => default
std::string trace_categories; // comma list (serial,mqtt,camera,control,all); empty => unset
std::string config_path; // resolved config.ini path (to save calibration back); empty => none
// --test: run this one self-test selection (the words after `test` at the
// console, e.g. "hardware" or "hardware motor") once the motor link is up,
// then exit with its verdict. Empty => normal operation. For scripts - the
// installer above all - which need an answer, not a dashboard.
std::string run_test;
};
// Exit codes of a --test run, beside main()'s 1 (config), 2 (lock) and
// Application's 3 (declared hardware missing).
constexpr int kExitTestFail = 4; // the test ran and failed
constexpr int kExitTestNotRun = 5; // the test could not start; the log says why
// Owns the component lifecycle and the control loop. Builds the concrete
// motor controller, control channel and camera source from config + options
// (real or mock), wires them to the ImagePipeline and CaptureScheduler, and

56
include/fgc/IniEdit.h Normal file
View File

@ -0,0 +1,56 @@
#pragma once
// Editing the two INI files a tower is configured by - config.ini and
// hardware.ini - without breaking them. Pure functions on file contents, so
// every rule is unit-testable; tools/fgc_config.cpp does the file IO around them.
//
// The rules exist because each has already broken a real file:
// - inih SPLITS a line longer than its buffer instead of truncating it, and the
// tail then parses as a bogus line of its own (see ini.h INI_MAX_LINE). A
// generated comment of exactly 200 characters once took out a whole config.
// - A value left in config.ini that hardware.ini also sets is dead, and warned
// about at every start. A placeholder left there is worse: deploy.sh's guard
// greps the raw text, so even a commented-out DEV_0000000000 trips it.
#include <map>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace fgc {
// The longest line content inih reads whole. Its buffer is INI_MAX_LINE bytes
// and holds the newline and the terminator too.
std::size_t iniMaxLineContent();
// 1-based number of the first line too long for inih to read whole, or 0.
int firstOverlongIniLine(const std::string& contents);
// "Camera.rgb.id" -> {"Camera.rgb", "id"}: the key is after the LAST dot, since
// section names may contain dots and key names never do. nullopt when there is
// no dot, or either side is empty.
std::optional<std::pair<std::string, std::string>> splitIniKey(const std::string& flat);
// Every key the contents set, as "Section.key" -> value, exactly as the program
// reads them. Throws std::runtime_error naming the line if inih rejects it.
std::map<std::string, std::string> parseIniString(const std::string& contents);
// Set "Section.key" = value pairs. Grouped per section and applied through
// updateIniSectionKeys(), so comments and order survive, a missing key is
// appended, and a non-empty `provenance` keeps one ";prev" generation per
// changed key. Throws std::invalid_argument on a malformed key.
std::string applyIniSets(const std::string& contents,
const std::vector<std::pair<std::string, std::string>>& sets,
const std::string& provenance);
// Replace each live `key = value` line whose "Section.key" is in `drops` with a
// one-line comment "; <key> <note>", where note is the map's value. The VALUE is
// deliberately not kept: a dead placeholder in a comment still trips deploy.sh's
// raw-text guard, and a dead real value invites someone to "fix" the wrong file.
// Returns the new contents; `dropped` (if given) receives what was replaced.
std::string dropIniKeys(const std::string& contents,
const std::map<std::string, std::string>& drops,
std::vector<std::string>* dropped = nullptr);
} // namespace fgc

View File

@ -122,7 +122,6 @@ public:
// encoder positions track the mock's live state.
// 5 = NEEDCFG, appended to the firmware enum so the older codes keep
// their meanings. See motor.h.
const int st = (needcfg_ && !configured_) ? 5 : (homed_ ? 3 : 1);
std::ostringstream d;
d << "DUMP BEGIN build=mock uptime=0 mcusr=0x01 free_ram=4096";
if (needcfg_ && pins_.spi_sck >= 0)
@ -153,6 +152,7 @@ public:
" irun=" + val("run_irun", "0") +
" irun_home=" + val("homing_irun", "0");
}
const int st = (needcfg_ && !axisConfigured(L)) ? 5 : (homed_ ? 3 : 1);
d << "DUMP " << L << " state=" << st
<< " hsub=0 enabled=" << (homed_ ? 1 : 0)
<< " lim_neg=0 lim_pos=1000000 hold_target=" << a.xenc
@ -205,8 +205,8 @@ public:
MotorTelemetry telemetry() override {
std::lock_guard<std::mutex> lock(mutex_);
step(yaw_, yaw_target_);
step(pitch_, pitch_target_);
step('Y', yaw_, yaw_target_);
step('P', pitch_, pitch_target_);
MotorTelemetry t;
t.yaw = yaw_;
t.pitch = pitch_;
@ -238,6 +238,15 @@ public:
return settings_;
}
bool configured() const { return configured_; }
// One axis has every key it asked for. The real firmware leaves NEEDCFG per
// axis, so a yaw-only tower - which rightly never answers for pitch - still
// gets a configured yaw.
bool axisConfigured(char L) const {
const std::string pre = std::string(1, L) + ".";
for (const auto& k : unanswered_)
if (k.compare(0, pre.size(), pre) == 0) return false;
return true;
}
bool connected() const override { return true; }
@ -251,7 +260,7 @@ private:
static constexpr long kStep = 80000; // counts advanced per telemetry read
void step(AxisTelemetry& a, long target) {
void step(char L, AxisTelemetry& a, long target) {
long d = target - a.xenc;
if (d >= -kStep && d <= kStep) {
a.xenc = target;
@ -263,7 +272,7 @@ private:
a.xactual = a.xenc;
// An axis waiting on its config says so, rather than looking like a
// healthy un-homed one - that distinction is the whole point of the state.
a.state = (needcfg_ && !configured_) ? AxisState::NeedConfig
a.state = (needcfg_ && !axisConfigured(L)) ? AxisState::NeedConfig
: (homed_ ? AxisState::Ready : AxisState::Reset);
a.deviation = deviation_;
}

View File

@ -70,6 +70,10 @@ int main(int argc, char* argv[]) {
("no-log-file", po::bool_switch(), "do not write a persistent log file this run")
("log-file-level", po::value<std::string>(),
"log-file verbosity, independent of --log-level (default debug)")
("test", po::value<std::string>(),
"run one self-test once the motor link is up, then exit: 0 PASS, 4 FAIL, 5 could "
"not start. Takes what follows `test` at the console, e.g. --test hardware or "
"--test \"hardware motor\". Headless; cannot be combined with --start")
("trace", po::value<std::string>(),
"verbatim wire trace, comma list: serial,mqtt,camera,control,all,none");
@ -85,6 +89,18 @@ int main(int argc, char* argv[]) {
std::cout << desc << "\n";
return 0;
}
if (vm.count("test")) {
const std::string t = vm["test"].as<std::string>();
if (t.find_first_not_of(" \t") == std::string::npos) {
std::cerr << "Argument error: --test needs a selection, e.g. --test hardware\n";
return 1;
}
if (vm["start"].as<bool>()) {
// Capture would move the gimbal and hold the serial link the test needs.
std::cerr << "Argument error: --test cannot be combined with --start\n";
return 1;
}
}
const std::string cli_cfg = vm.count("config") ? vm["config"].as<std::string>() : "";
auto cfg_path = paths::resolveConfigPath(cli_cfg);
@ -200,6 +216,10 @@ int main(int argc, char* argv[]) {
if (vm.count("log-level")) opts.log_level = vm["log-level"].as<std::string>();
if (vm.count("trace")) opts.trace_categories = vm["trace"].as<std::string>();
opts.config_path = *cfg_path; // so the setup commands can save back to it
if (vm.count("test")) {
opts.run_test = vm["test"].as<std::string>();
opts.use_tui = false; // a script wants lines on stdout, not a dashboard
}
Application app(std::move(cfg), std::move(opts));
try {

View File

@ -280,6 +280,11 @@ struct Application::Impl {
bool heading_setup_active_ = false;
std::atomic<bool> running{true};
// --test: what run() returns, and where the one-shot test has got to.
int exit_code_ = 0;
bool oneshot_started_ = false;
std::chrono::steady_clock::time_point oneshot_since_{};
std::chrono::steady_clock::time_point oneshot_link_ok_{};
std::mutex cmd_mutex;
std::queue<std::string> cmd_queue;
@ -2325,6 +2330,10 @@ struct Application::Impl {
last_test_view.pass = r.all_pass;
last_test_view.profile = r.profile;
last_test_view.summary = last_test_summary;
if (!opts.run_test.empty()) {
exit_code_ = r.all_pass ? 0 : kExitTestFail;
running = false;
}
}
}
unsigned seq = motor->diagSeq();
@ -2482,6 +2491,49 @@ struct Application::Impl {
}
}
// --test: start the requested test once the firmware is talking AND has been
// told its drivetrain, then let pollBackgroundResults() end the run with the
// verdict.
//
// Not immediately at startup: the firmware's NEED is answered from inside the
// control loop, so a DUMP taken on the first tick can catch an axis still in
// NEEDCFG and fail `test hardware motor` on values nobody had sent yet - a
// false alarm that would teach people to ignore the test. A deadline stops a
// dead link from waiting forever; the test then runs anyway and reports the
// real failure rather than this function inventing one.
void driveOneShotTest() {
if (opts.run_test.empty() || oneshot_started_) return;
using namespace std::chrono;
const auto now = steady_clock::now();
const MotorTelemetry tel = motor->telemetry();
auto axisSettled = [](AxisState a) {
return a == AxisState::Reset || a == AxisState::Ready || a == AxisState::Error;
};
// Pitch counts only where it is both expected and reporting: a yaw-only
// firmware has no P segment to wait for.
const AxisState st = tel.yaw.state;
const bool settled =
axisSettled(st) && (!cfg.hardware.hasAxis("pitch") || !tel.pitch_present ||
axisSettled(tel.pitch.state));
if (!settled) oneshot_link_ok_ = {};
else if (oneshot_link_ok_ == steady_clock::time_point{}) oneshot_link_ok_ = now;
const bool grace_over = settled && now - oneshot_link_ok_ >= seconds(1);
const bool timed_out = now - oneshot_since_ >= seconds(20);
if (!grace_over && !timed_out) return;
if (!grace_over)
LOG_WARN << "--test: the motor link did not settle within 20 s (an axis is still "
"booting, homing or waiting for its settings); running the test anyway";
oneshot_started_ = true;
handleTest("test " + opts.run_test);
if (!tester) {
LOG_ERROR << "--test: '" << opts.run_test << "' did not start (see above)";
exit_code_ = kExitTestNotRun;
running = false;
}
}
void drainCommands() {
std::queue<std::string> local;
{
@ -2669,8 +2721,10 @@ struct Application::Impl {
std::signal(SIGTERM, handleShutdownSignal);
LOG_INFO << "Entering control loop (type 'exit' to quit, or Ctrl-C)";
oneshot_since_ = std::chrono::steady_clock::now();
while (running && !g_shutdown_requested.load()) {
drainCommands();
driveOneShotTest();
answerFirmwareNeeds(); // the firmware asks; hardware.ini answers
pollBackgroundResults(); // apply calibration result / emit DIAG summary+log
scheduler->tick();
@ -2690,7 +2744,9 @@ struct Application::Impl {
if (env) env->stop();
motor->stop();
channel->disconnect();
return 0;
// A --test run interrupted before its verdict has no verdict to report.
if (!opts.run_test.empty() && running && exit_code_ == 0) exit_code_ = kExitTestNotRun;
return exit_code_;
}
};

118
src/core/IniEdit.cpp Normal file
View File

@ -0,0 +1,118 @@
#include "fgc/IniEdit.h"
#include "fgc/Config.h"
#include "ini.h"
#include <cctype>
#include <sstream>
#include <stdexcept>
namespace fgc {
namespace {
std::string trim(const std::string& s) {
const char* ws = " \t\r\n";
const size_t a = s.find_first_not_of(ws);
if (a == std::string::npos) return "";
const size_t b = s.find_last_not_of(ws);
return s.substr(a, b - a + 1);
}
int collect(void* user, const char* section, const char* name, const char* value) {
auto* kv = static_cast<std::map<std::string, std::string>*>(user);
(*kv)[std::string(section) + "." + name] = value ? value : "";
return 1;
}
} // namespace
std::size_t iniMaxLineContent() { return INI_MAX_LINE - 2; }
int firstOverlongIniLine(const std::string& contents) {
std::istringstream in(contents);
std::string line;
int n = 0;
while (std::getline(in, line)) {
++n;
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.size() > iniMaxLineContent()) return n;
}
return 0;
}
std::optional<std::pair<std::string, std::string>> splitIniKey(const std::string& flat) {
const size_t dot = flat.rfind('.');
if (dot == std::string::npos || dot == 0 || dot + 1 == flat.size()) return std::nullopt;
return std::make_pair(flat.substr(0, dot), flat.substr(dot + 1));
}
std::map<std::string, std::string> parseIniString(const std::string& contents) {
std::map<std::string, std::string> kv;
const int rc = ini_parse_string(contents.c_str(), collect, &kv);
if (rc != 0)
throw std::runtime_error("does not parse as INI at line " + std::to_string(rc));
return kv;
}
std::string applyIniSets(const std::string& contents,
const std::vector<std::pair<std::string, std::string>>& sets,
const std::string& provenance) {
// Per section, in first-mention order, so appended blocks come out in the
// order the caller listed them.
std::vector<std::string> order;
std::map<std::string, std::vector<std::pair<std::string, std::string>>> by_section;
for (const auto& [flat, value] : sets) {
const auto k = splitIniKey(flat);
if (!k) throw std::invalid_argument("'" + flat + "' is not Section.key");
if (value.find('\n') != std::string::npos)
throw std::invalid_argument(flat + ": a value cannot span lines");
if (!by_section.count(k->first)) order.push_back(k->first);
by_section[k->first].emplace_back(k->second, value);
}
std::string out = contents;
for (const auto& section : order)
out = updateIniSectionKeys(out, section, by_section[section], provenance);
return out;
}
std::string dropIniKeys(const std::string& contents,
const std::map<std::string, std::string>& drops,
std::vector<std::string>* dropped) {
std::istringstream in(contents);
std::ostringstream out;
std::string line, section;
bool in_dropped_value = false;
while (std::getline(in, line)) {
const std::string t = trim(line);
// With INI_ALLOW_MULTILINE, an indented line continues the value above
// it. Once that key's line is gone, its continuation would attach itself
// to whichever key precedes it - so it goes too.
if (in_dropped_value && !line.empty() && std::isspace((unsigned char)line[0]) &&
!t.empty() && t[0] != ';' && t[0] != '#') {
out << "; " << t << "\n";
continue;
}
in_dropped_value = false;
if (t.size() >= 2 && t.front() == '[' && t.back() == ']') {
section = trim(t.substr(1, t.size() - 2));
} else if (!t.empty() && t[0] != ';' && t[0] != '#') {
const size_t eq = line.find('=');
if (eq != std::string::npos) {
const std::string key = trim(line.substr(0, eq));
const std::string flat = section + "." + key;
auto it = drops.find(flat);
if (it != drops.end()) {
out << "; " << key << " " << it->second << "\n";
if (dropped) dropped->push_back(flat);
in_dropped_value = true;
continue;
}
}
}
out << line << "\n";
}
return out.str();
}
} // namespace fgc

View File

@ -22,6 +22,7 @@ add_executable(fgc_tests
test_paths.cpp
test_singleinstance.cpp
test_config.cpp
test_iniedit.cpp
test_tmccurrent.cpp
test_hardwarepresence.cpp
test_hardwarechecks.cpp

View File

@ -263,3 +263,43 @@ TEST_CASE("RESET is not a way out of NEEDCFG") {
CHECK_FALSE(m.telemetry().yaw.ready());
CHECK(m.telemetry().yaw.state == AxisState::NeedConfig);
}
TEST_CASE("the mock leaves NEEDCFG per axis, as the firmware does") {
// A yaw-only tower never answers for pitch - the host refuses to configure
// an axis the tower does not declare. Yaw must still report configured, or
// anything waiting on it (--test, above all) waits for nothing.
MockMotorController m(/*needcfg=*/true);
m.start();
auto lines = m.takeNeedLines();
REQUIRE(lines.size() == 2);
AxisConfigSource src;
src.have_current = true;
src.global_scaler = 80;
src.center_count = 500000;
src.soft_margin = 5000;
src.have_travel = true;
src.steps_per_rev = 1210778;
src.have_deviation = true;
src.enc_deviation = 1500;
src.normal_vmax = 39738;
src.homing_vmax = 30000;
src.center_vmax = 25000;
src.have_wiring = true;
auto yaw = parseNeedLine(lines[0]);
REQUIRE(yaw);
for (const auto& cmd : planFirmwareConfig(*yaw, src).sets) m.sendCommand(cmd);
CHECK(m.axisConfigured('Y'));
CHECK_FALSE(m.axisConfigured('P'));
CHECK_FALSE(m.configured()); // "every axis" keeps its meaning
const auto t = m.telemetry();
CHECK(t.yaw.state == AxisState::Reset);
CHECK(t.pitch.state == AxisState::NeedConfig);
m.sendCommand("DUMP");
const std::string d = m.lastDump();
CHECK(d.find("DUMP Y state=1") != std::string::npos);
CHECK(d.find("DUMP P state=5") != std::string::npos);
}

106
tests/test_iniedit.cpp Normal file
View File

@ -0,0 +1,106 @@
#include <doctest/doctest.h>
#include "fgc/IniEdit.h"
#include <stdexcept>
#include <string>
using namespace fgc;
TEST_CASE("a Section.key splits on its LAST dot, since sections carry dots") {
auto k = splitIniKey("Camera.rgb.id");
REQUIRE(k);
CHECK(k->first == "Camera.rgb");
CHECK(k->second == "id");
CHECK_FALSE(splitIniKey("nodot"));
CHECK_FALSE(splitIniKey(".key"));
CHECK_FALSE(splitIniKey("Section."));
}
TEST_CASE("the line limit is the one inih actually reads whole") {
// 200-byte buffer, minus the newline and the terminator.
CHECK(iniMaxLineContent() == 198);
const std::string ok(198, 'x');
const std::string too_long(199, 'x');
CHECK(firstOverlongIniLine("a = 1\n" + ok + "\n") == 0);
CHECK(firstOverlongIniLine("a = 1\n" + too_long + "\nb = 2\n") == 2);
// A CRLF file is measured without its \r.
CHECK(firstOverlongIniLine(ok + "\r\n") == 0);
}
TEST_CASE("parseIniString reads keys as the program does, and names a bad line") {
auto kv = parseIniString("[General]\ntower_name = t1 ; note\n[Camera.rgb]\nid = D\n");
CHECK(kv.at("General.tower_name") == "t1");
CHECK(kv.at("Camera.rgb.id") == "D");
CHECK_THROWS_WITH(parseIniString("[General]\nno equals sign here\n"),
doctest::Contains("line 2"));
}
TEST_CASE("applyIniSets edits in place, appends what is missing, keeps comments") {
const std::string in =
"; header\n"
"[General]\n"
"; the name\n"
"tower_name = ExampleTower\n"
"[Network]\n"
"zkms_server_ip = 127.0.0.1\n";
const std::string out = applyIniSets(
in, {{"General.tower_name", "north"}, {"Motor.yaw_center_count", "1000000"}}, "");
CHECK(out.find("; header\n") == 0);
CHECK(out.find("; the name\ntower_name = north\n") != std::string::npos);
CHECK(out.find("ExampleTower") == std::string::npos);
CHECK(out.find("[Motor]\nyaw_center_count = 1000000\n") != std::string::npos);
CHECK(parseIniString(out).at("Network.zkms_server_ip") == "127.0.0.1");
}
TEST_CASE("applyIniSets keeps one ;prev generation when given a provenance") {
const std::string in = "[Serial]\ndevice = /dev/serial/by-id/old\n";
const std::string out =
applyIniSets(in, {{"Serial.device", "/dev/serial/by-id/new"}}, "install-tower");
CHECK(out.find(";prev device = /dev/serial/by-id/old") != std::string::npos);
CHECK(parseIniString(out).at("Serial.device") == "/dev/serial/by-id/new");
}
TEST_CASE("applyIniSets refuses a malformed key or a multi-line value") {
CHECK_THROWS_AS(applyIniSets("", {{"nodot", "1"}}, ""), std::invalid_argument);
CHECK_THROWS_AS(applyIniSets("", {{"A.b", "1\nc = 2"}}, ""), std::invalid_argument);
}
TEST_CASE("dropIniKeys comments a key out WITHOUT its value") {
// The placeholder must not survive even as a comment: deploy.sh's guard greps
// the raw file for DEV_0000000000 and would refuse to run the tower.
const std::string in =
"[Camera.rgb]\n"
"enabled = true\n"
"id = DEV_0000000000 ; USB DEV_ id\n"
"[Serial]\n"
"device = /dev/ttyACM0\n";
std::vector<std::string> dropped;
const std::string out = dropIniKeys(
in, {{"Camera.rgb.id", "is set in hardware.ini"}, {"Serial.device", "is set elsewhere"}},
&dropped);
CHECK(out.find("DEV_0000000000") == std::string::npos);
CHECK(out.find("; id is set in hardware.ini\n") != std::string::npos);
CHECK(out.find("; device is set elsewhere\n") != std::string::npos);
CHECK(dropped == std::vector<std::string>{"Camera.rgb.id", "Serial.device"});
const auto kv = parseIniString(out);
CHECK(kv.count("Camera.rgb.id") == 0);
CHECK(kv.at("Camera.rgb.enabled") == "true");
}
TEST_CASE("dropIniKeys matches the section too, not just the key name") {
const std::string in = "[IMU]\ndevice = /dev/ttyS0\n[Serial]\ndevice = /dev/ttyACM0\n";
const auto kv = parseIniString(dropIniKeys(in, {{"Serial.device", "gone"}}));
CHECK(kv.at("IMU.device") == "/dev/ttyS0");
CHECK(kv.count("Serial.device") == 0);
}
TEST_CASE("dropIniKeys takes a dropped key's continuation lines with it") {
// inih joins an indented line onto the value above it. Left behind, it would
// attach itself to whatever key now precedes it.
const std::string in = "[A]\nkeep = 1\ngone = first\n second\nafter = 2\n";
const auto kv = parseIniString(dropIniKeys(in, {{"A.gone", "x"}}));
CHECK(kv.at("A.keep") == "1");
CHECK(kv.at("A.after") == "2");
CHECK(kv.count("A.gone") == 0);
}

298
tools/fgc_config.cpp Normal file
View File

@ -0,0 +1,298 @@
// fgc-config - write and edit a tower's config.ini and hardware.ini so that
// what lands on disk is always a file fire_gimbal_control will accept.
//
// fgc-config seed --example <config.example.ini> --out <config.ini>
// [--hardware <hardware.ini>] [--set Section.key=value]...
// [--drop Section.key=note]... [--dry-run]
// fgc-config set <file> --config <config.ini> [--hardware <hardware.ini>]
// --set Section.key=value... [--provenance <text>] [--dry-run]
// fgc-config get <file> Section.key
// fgc-config check --config <config.ini> [--hardware <hardware.ini>]
//
// seed makes a NEW config.ini from the example: applies the --set values, and
// replaces every key that hardware.ini sets with a one-line comment
// saying so. Refuses if the output exists - a tower's config.ini holds
// its calibration, and is edited with `set`, never regenerated.
// set edits one of the two files in place: comments and order are kept, and
// each changed key keeps its previous value as a ";prev" comment.
// get prints a value as the program reads it (nothing, exit 1, if unset).
// check validates the pair and changes nothing.
//
// Every write is validated FIRST, by loading the candidate through the same
// ConfigLoader the program uses (with the other file layered as it will be),
// and by refusing any line too long for inih to read whole. Only then is it
// renamed into place, so a failed write leaves the old file untouched.
// --dry-run prints the candidate instead of writing it; the installer diffs it.
//
// Exit: 0 ok, 1 usage or refused, 2 the result would not load.
#include "fgc/Config.h"
#include "fgc/IniEdit.h"
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <map>
#include <sstream>
#include <string>
#include <vector>
namespace fs = std::filesystem;
using namespace fgc;
namespace {
constexpr int kUsage = 1;
constexpr int kInvalid = 2;
int usage(const std::string& why) {
if (!why.empty()) std::cerr << "fgc-config: " << why << "\n";
std::cerr << "usage:\n"
" fgc-config seed --example E --out O [--hardware H] [--set S.k=v]... "
"[--drop S.k=note]... [--dry-run]\n"
" fgc-config set FILE --config C [--hardware H] --set S.k=v... "
"[--provenance P] [--dry-run]\n"
" fgc-config get FILE S.k\n"
" fgc-config check --config C [--hardware H]\n";
return kUsage;
}
bool readFile(const std::string& path, std::string& out) {
std::ifstream f(path, std::ios::binary);
if (!f) return false;
std::ostringstream ss;
ss << f.rdbuf();
out = ss.str();
return true;
}
bool writeFile(const std::string& path, const std::string& text) {
std::ofstream f(path, std::ios::binary | std::ios::trunc);
f << text;
f.flush();
return static_cast<bool>(f);
}
bool splitAssign(const std::string& s, std::pair<std::string, std::string>& out) {
const size_t eq = s.find('=');
if (eq == std::string::npos || eq == 0) return false;
out = {s.substr(0, eq), s.substr(eq + 1)};
return true;
}
struct Args {
std::string cmd, file, example, out, config, hardware, provenance, key;
std::vector<std::pair<std::string, std::string>> sets;
std::map<std::string, std::string> drops;
bool dry_run = false;
};
// Load the pair the way the program will. `cfg`/`hw` are the paths to load; the
// candidate being written is passed in their place. Returns false and prints why.
bool loads(const std::string& cfg, const std::string& hw) {
try {
(void)ConfigLoader::loadFromFile(cfg, hw);
return true;
} catch (const std::exception& e) {
std::cerr << "fgc-config: the result would not load: " << e.what() << "\n";
return false;
}
}
bool longLinesOk(const std::string& text, const std::string& what) {
if (int n = firstOverlongIniLine(text)) {
std::cerr << "fgc-config: " << what << " line " << n << " is longer than "
<< iniMaxLineContent()
<< " characters; inih would split it and misread the rest of the file\n";
return false;
}
return true;
}
// A sibling temp file, so the final rename stays on one filesystem and is atomic.
std::string tempBeside(const std::string& path) {
return (fs::path(path).parent_path() / ("." + fs::path(path).filename().string() +
".fgc-config.tmp")).string();
}
// Validate `text` as the new `target`, then write it. `other` is the file layered
// with it (hardware.ini for a config.ini, config.ini for a hardware.ini), and
// `target_is_hw` says which role the target plays.
int commit(const std::string& target, const std::string& text, const std::string& other,
bool target_is_hw, bool dry_run) {
if (!longLinesOk(text, target)) return kInvalid;
const std::string tmp = tempBeside(target);
if (!writeFile(tmp, text)) {
std::cerr << "fgc-config: cannot write " << tmp << "\n";
return kUsage;
}
const bool ok = target_is_hw ? loads(other, tmp) : loads(tmp, other);
if (!ok || dry_run) {
std::error_code ec;
fs::remove(tmp, ec);
if (ok) std::cout << text;
return ok ? 0 : kInvalid;
}
std::error_code ec;
if (fs::exists(target))
fs::permissions(tmp, fs::status(target).permissions(), ec);
else
fs::permissions(tmp, fs::perms::owner_read | fs::perms::owner_write |
fs::perms::group_read | fs::perms::others_read, ec);
fs::rename(tmp, target, ec);
if (ec) {
std::cerr << "fgc-config: cannot replace " << target << ": " << ec.message() << "\n";
fs::remove(tmp, ec);
return kUsage;
}
std::cerr << "fgc-config: wrote " << target << "\n";
return 0;
}
int cmdSeed(const Args& a) {
if (a.example.empty() || a.out.empty()) return usage("seed needs --example and --out");
if (fs::exists(a.out) && !a.dry_run) {
std::cerr << "fgc-config: " << a.out << " exists. A tower's config.ini holds its "
"calibration: edit it with `fgc-config set`, never regenerate it\n";
return kUsage;
}
std::string text;
if (!readFile(a.example, text)) return usage("cannot read " + a.example);
std::map<std::string, std::string> drops = a.drops;
if (!a.hardware.empty()) {
std::string hw;
if (!readFile(a.hardware, hw)) return usage("cannot read " + a.hardware);
try {
const auto hw_kv = parseIniString(hw);
for (const auto& [k, v] : hw_kv) {
(void)v;
drops.emplace(k, "is set in hardware.ini, which wins over this file");
}
// The enable flags exist for towers WITHOUT a hardware.ini. Where the
// file declares the sensor, the declaration decides and a flag beside
// it is warned about at every start, so a new tower does not get one.
if (hw_kv.count("Hardware.imu"))
drops.emplace("Features.enable_imu", "follows [Hardware] imu in hardware.ini");
if (hw_kv.count("Hardware.env"))
drops.emplace("Features.enable_env", "follows [Hardware] env in hardware.ini");
} catch (const std::exception& e) {
std::cerr << "fgc-config: " << a.hardware << " " << e.what() << "\n";
return kInvalid;
}
}
std::vector<std::string> dropped;
text = dropIniKeys(text, drops, &dropped);
try {
text = applyIniSets(text, a.sets, /*provenance=*/"");
} catch (const std::exception& e) {
return usage(e.what());
}
for (const auto& k : dropped)
std::cerr << "fgc-config: commented out " << k << " (" << drops.at(k) << ")\n";
// The candidate is validated beside its final location, so profile_dir and
// other relative paths resolve exactly as they will for the program.
return commit(a.out, text, a.hardware, /*target_is_hw=*/false, a.dry_run);
}
int cmdSet(const Args& a) {
if (a.file.empty() || a.config.empty()) return usage("set needs FILE and --config");
if (a.sets.empty()) return usage("set needs at least one --set");
std::error_code ec;
const bool is_cfg = fs::equivalent(a.file, a.config, ec);
const bool is_hw = !a.hardware.empty() && fs::equivalent(a.file, a.hardware, ec);
if (!is_cfg && !is_hw) return usage("FILE must be the --config or the --hardware file");
std::string text;
if (!readFile(a.file, text)) return usage("cannot read " + a.file);
try {
text = applyIniSets(text, a.sets, a.provenance);
} catch (const std::exception& e) {
return usage(e.what());
}
return is_hw ? commit(a.hardware, text, a.config, true, a.dry_run)
: commit(a.config, text, a.hardware, false, a.dry_run);
}
int cmdGet(const Args& a) {
if (a.file.empty() || a.key.empty()) return usage("get needs FILE and Section.key");
std::string text;
if (!readFile(a.file, text)) return usage("cannot read " + a.file);
try {
const auto kv = parseIniString(text);
auto it = kv.find(a.key);
if (it == kv.end()) return kUsage;
std::cout << it->second << "\n";
return 0;
} catch (const std::exception& e) {
std::cerr << "fgc-config: " << a.file << " " << e.what() << "\n";
return kInvalid;
}
}
int cmdCheck(const Args& a) {
if (a.config.empty()) return usage("check needs --config");
for (const std::string& f : {a.config, a.hardware}) {
if (f.empty()) continue;
std::string text;
if (!readFile(f, text)) return usage("cannot read " + f);
if (!longLinesOk(text, f)) return kInvalid;
}
if (!loads(a.config, a.hardware)) return kInvalid;
std::cerr << "fgc-config: " << a.config << (a.hardware.empty() ? "" : " + " + a.hardware)
<< " load cleanly\n";
return 0;
}
} // namespace
int main(int argc, char** argv) {
if (argc < 2) return usage("");
Args a;
a.cmd = argv[1];
std::vector<std::string> positional;
for (int i = 2; i < argc; ++i) {
const std::string s = argv[i];
auto value = [&](std::string& dst) {
if (i + 1 >= argc) return false;
dst = argv[++i];
return true;
};
std::pair<std::string, std::string> kv;
std::string v;
if (s == "--example") { if (!value(a.example)) return usage(s + " needs a value"); }
else if (s == "--out") { if (!value(a.out)) return usage(s + " needs a value"); }
else if (s == "--config") { if (!value(a.config)) return usage(s + " needs a value"); }
else if (s == "--hardware") { if (!value(a.hardware)) return usage(s + " needs a value"); }
else if (s == "--provenance") { if (!value(a.provenance)) return usage(s + " needs a value"); }
else if (s == "--dry-run") { a.dry_run = true; }
else if (s == "--set") {
if (!value(v) || !splitAssign(v, kv)) return usage("--set takes Section.key=value");
a.sets.push_back(kv);
} else if (s == "--drop") {
if (!value(v) || !splitAssign(v, kv)) return usage("--drop takes Section.key=note");
a.drops[kv.first] = kv.second;
} else if (s.rfind("--", 0) == 0) {
return usage("unknown option " + s);
} else {
positional.push_back(s);
}
}
if (a.cmd == "seed" || a.cmd == "check") {
if (!positional.empty()) return usage("unexpected argument " + positional[0]);
} else if (a.cmd == "set") {
if (positional.size() != 1) return usage("set takes exactly one FILE");
a.file = positional[0];
} else if (a.cmd == "get") {
if (positional.size() != 2) return usage("get takes FILE and Section.key");
a.file = positional[0];
a.key = positional[1];
}
if (a.cmd == "seed") return cmdSeed(a);
if (a.cmd == "set") return cmdSet(a);
if (a.cmd == "get") return cmdGet(a);
if (a.cmd == "check") return cmdCheck(a);
return usage("unknown command '" + a.cmd + "'");
}