299 lines
12 KiB
C++
299 lines
12 KiB
C++
// 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 + "'");
|
|
}
|