fwt_software/src/core/Config.cpp

412 lines
18 KiB
C++

#include "fgc/Config.h"
#include "fgc/Paths.h"
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <stdexcept>
extern "C" {
#include "ini.h"
}
namespace fgc {
namespace {
std::string get(const std::map<std::string, std::string>& kv, const std::string& key,
const std::string& fallback = "") {
auto it = kv.find(key);
return (it != kv.end() && !it->second.empty()) ? it->second : fallback;
}
int getInt(const std::map<std::string, std::string>& kv, const std::string& key, int fallback) {
auto it = kv.find(key);
if (it == kv.end() || it->second.empty()) return fallback;
try {
// Reject trailing characters: bare std::stoi stops at the first non-digit,
// so "1883x" would quietly become 1883 and a typo'd value would configure
// something the operator never wrote. Same reasoning as getIntAutoBase below.
size_t consumed = 0;
int out = std::stoi(it->second, &consumed);
if (consumed != it->second.size()) throw std::invalid_argument("trailing characters");
return out;
} catch (const std::exception&) {
throw std::runtime_error("Config key '" + key + "' must be an integer, got '" +
it->second + "'");
}
}
// Like getInt, but accepts an "0x"-prefixed value too. Only for I2C addresses,
// which every datasheet, `i2cdetect` and our own docs write in hex: plain
// std::stoi() stops at the 'x' and silently yields 0, so `i2c_addr = 0x44`
// would configure address 0x00 and the sensor would never answer. Kept separate
// from getInt so no other key gains surprise octal ("010" -> 8) parsing.
int getIntAutoBase(const std::map<std::string, std::string>& kv, const std::string& key,
int fallback) {
auto it = kv.find(key);
if (it == kv.end() || it->second.empty()) return fallback;
try {
size_t consumed = 0;
const std::string& v = it->second;
int out = std::stoi(v, &consumed, v.rfind("0x", 0) == 0 || v.rfind("0X", 0) == 0 ? 16 : 10);
if (consumed != v.size()) throw std::invalid_argument("trailing characters");
return out;
} catch (const std::exception&) {
throw std::runtime_error("Config key '" + key + "' must be an integer (decimal or 0x hex)"
", got '" + it->second + "'");
}
}
double getDouble(const std::map<std::string, std::string>& kv, const std::string& key,
double fallback) {
auto it = kv.find(key);
if (it == kv.end() || it->second.empty()) return fallback;
try {
return std::stod(it->second);
} catch (const std::exception&) {
throw std::runtime_error("Config key '" + key + "' must be a number, got '" +
it->second + "'");
}
}
long getLong(const std::map<std::string, std::string>& kv, const std::string& key, long fallback) {
auto it = kv.find(key);
if (it == kv.end() || it->second.empty()) return fallback;
try {
return std::stol(it->second);
} catch (const std::exception&) {
throw std::runtime_error("Config key '" + key + "' must be an integer, got '" +
it->second + "'");
}
}
bool getBool(const std::map<std::string, std::string>& kv, const std::string& key, bool fallback) {
auto it = kv.find(key);
if (it == kv.end() || it->second.empty()) return fallback;
const std::string& v = it->second;
if (v == "1" || v == "true" || v == "yes" || v == "on") return true;
if (v == "0" || v == "false" || v == "no" || v == "off") return false;
throw std::runtime_error("Config key '" + key + "' must be a boolean, got '" + v + "'");
}
std::string envOr(const char* name, const std::string& fallback) {
const char* v = std::getenv(name);
return (v && *v) ? std::string(v) : fallback;
}
// inih callback: flatten "Section.name" => value into the map.
int iniHandler(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) + "." + std::string(name)] = value ? value : "";
return 1;
}
} // namespace
std::string NetworkConfig::brokerUri() const {
// A scheme means the operator wrote the whole URI: hands off. Appending a port
// could corrupt one that already has it, or a ws:// path.
if (broker_ip.find("://") != std::string::npos) return broker_ip;
std::string host = broker_ip;
bool has_port = false;
if (!host.empty() && host.front() == '[') { // bracketed IPv6
const size_t close = host.rfind(']');
has_port = close != std::string::npos && host.find(':', close) != std::string::npos;
} else {
const size_t colons = static_cast<size_t>(std::count(host.begin(), host.end(), ':'));
if (colons == 1) {
has_port = true;
} else if (colons > 1) {
host = "[" + host + "]"; // bare IPv6; bracket it so the port is unambiguous
}
}
if (has_port) return "tcp://" + host;
return "tcp://" + host + ":" + std::to_string(mqtt_port);
}
double AppConfig::image_rate() const {
return general.image_interval > 0 ? 1.0 / general.image_interval : 0.0;
}
double TestProfile::get(const std::string& key, double fallback) const {
auto it = params.find(key);
return it != params.end() ? it->second : fallback;
}
int TestProfile::geti(const std::string& key, int fallback) const {
auto it = params.find(key);
return it != params.end() ? static_cast<int>(it->second) : fallback;
}
bool TestProfile::getBool(const std::string& key, bool fallback) const {
auto it = text.find(key);
if (it == text.end() || it->second.empty()) return fallback;
const std::string& v = it->second;
return v == "1" || v == "true" || v == "yes" || v == "on";
}
std::string TestProfile::gets(const std::string& key, const std::string& fallback) const {
auto it = text.find(key);
return (it != text.end() && !it->second.empty()) ? it->second : fallback;
}
std::vector<long> TestProfile::getLongList(const std::string& key) const {
std::vector<long> out;
auto it = text.find(key);
if (it == text.end()) return out;
std::stringstream ss(it->second);
std::string tok;
while (std::getline(ss, tok, ',')) {
try { out.push_back(std::stol(tok)); } catch (...) {}
}
return out;
}
const TestProfile* TestConfig::find(const std::string& name) const {
auto it = profiles.find(name);
return it != profiles.end() ? &it->second : nullptr;
}
AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
AppConfig cfg;
cfg.general.tower_name = get(kv, "General.tower_name", cfg.general.tower_name);
cfg.general.image_interval = getInt(kv, "General.image_interval", cfg.general.image_interval);
cfg.general.debug = getBool(kv, "General.debug", cfg.general.debug);
cfg.network.broker_ip = get(kv, "Network.zkms_server_ip", cfg.network.broker_ip);
cfg.network.mqtt_port = getInt(kv, "Network.mqtt_port", cfg.network.mqtt_port);
if (cfg.network.mqtt_port < 1 || cfg.network.mqtt_port > 65535)
throw std::runtime_error("Network.mqtt_port must be in 1..65535");
// Secrets: environment variables win over the file.
cfg.network.mqtt_user = envOr("FGC_MQTT_USER", get(kv, "Network.mqtt_user"));
cfg.network.mqtt_pw = envOr("FGC_MQTT_PW", get(kv, "Network.mqtt_pw"));
cfg.serial.device = get(kv, "Serial.device", cfg.serial.device);
cfg.serial.baud = static_cast<unsigned>(getInt(kv, "Serial.baud", cfg.serial.baud));
cfg.camera.ids.clear();
for (const char* key : {"Camera.id_Cam1", "Camera.id_Cam2", "Camera.id_Cam3", "Camera.id_Cam4"}) {
std::string id = get(kv, key);
if (!id.empty()) cfg.camera.ids.push_back(id);
}
// On-camera imaging + encoding. See CameraConfig for why the defaults are conservative.
cfg.camera.mode_dir = get(kv, "Camera.mode_dir", cfg.camera.mode_dir);
cfg.camera.mode = get(kv, "Camera.mode", cfg.camera.mode);
cfg.camera.jxl_distance = getDouble(kv, "Camera.jxl_distance", cfg.camera.jxl_distance);
cfg.camera.jxl_effort = getInt(kv, "Camera.jxl_effort", cfg.camera.jxl_effort);
cfg.camera.rotation_deg = getInt(kv, "Camera.rotation_deg", cfg.camera.rotation_deg);
if (cfg.camera.jxl_distance < 0.0)
throw std::runtime_error("Camera.jxl_distance must be >= 0 (0 = lossless)");
if (cfg.camera.jxl_effort < 1 || cfg.camera.jxl_effort > 9)
throw std::runtime_error("Camera.jxl_effort must be in 1..9");
if (cfg.camera.rotation_deg != 0 && cfg.camera.rotation_deg != 90 &&
cfg.camera.rotation_deg != 180 && cfg.camera.rotation_deg != 270)
throw std::runtime_error("Camera.rotation_deg must be 0, 90, 180 or 270");
std::string out = get(kv, "Paths.output_dir");
cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out);
cfg.features.enable_mqtt = getBool(kv, "Features.enable_mqtt", cfg.features.enable_mqtt);
cfg.features.enable_camera = getBool(kv, "Features.enable_camera", cfg.features.enable_camera);
cfg.features.enable_serial = getBool(kv, "Features.enable_serial", cfg.features.enable_serial);
cfg.features.enable_imu = getBool(kv, "Features.enable_imu", cfg.features.enable_imu);
cfg.features.mock_camera = getBool(kv, "Features.mock_camera", cfg.features.mock_camera);
cfg.features.mock_serial = getBool(kv, "Features.mock_serial", cfg.features.mock_serial);
cfg.features.mock_imu = getBool(kv, "Features.mock_imu", cfg.features.mock_imu);
cfg.features.enable_env = getBool(kv, "Features.enable_env", cfg.features.enable_env);
cfg.features.mock_env = getBool(kv, "Features.mock_env", cfg.features.mock_env);
cfg.imu.device = get(kv, "IMU.device", cfg.imu.device);
cfg.imu.baud = static_cast<unsigned>(getInt(kv, "IMU.baud", cfg.imu.baud));
cfg.env.i2c_device = get(kv, "Env.i2c_device", cfg.env.i2c_device);
cfg.env.i2c_addr = static_cast<unsigned>(
getIntAutoBase(kv, "Env.i2c_addr", static_cast<int>(cfg.env.i2c_addr)));
cfg.env.period_ms = getInt(kv, "Env.period_ms", cfg.env.period_ms);
// [Test] + [TestProfile.<name>]: discover profiles by scanning the flattened
// keys. inih gives us "TestProfile.<name>.<key>" => value; every numeric value
// lands in that profile's open param map for the test modules to read.
cfg.test.default_profile = get(kv, "Test.profile", cfg.test.default_profile);
cfg.test.baseline_file = get(kv, "Test.baseline_file");
if (!cfg.test.baseline_file.empty())
cfg.test.baseline_file = paths::expandUser(cfg.test.baseline_file);
{
const std::string prefix = "TestProfile.";
for (const auto& [key, val] : kv) {
if (key.rfind(prefix, 0) != 0) continue;
auto dot = key.find('.', prefix.size());
if (dot == std::string::npos) continue;
std::string pname = key.substr(prefix.size(), dot - prefix.size());
std::string pkey = key.substr(dot + 1);
if (pname.empty() || pkey.empty() || val.empty()) continue;
auto& prof = cfg.test.profiles[pname];
prof.name = pname;
prof.text[pkey] = val; // raw string (covers comma lists)
try {
prof.params[pkey] = std::stod(val); // also numeric where possible
} catch (...) {
// list/non-numeric value: the raw string in `text` is what modules read
}
}
}
cfg.logging.level = get(kv, "Logging.level", cfg.logging.level);
cfg.logging.trace = get(kv, "Logging.trace", cfg.logging.trace);
cfg.ui.enable_tui = getBool(kv, "UI.enable_tui", cfg.ui.enable_tui);
// [Motor]: operator-calibrated degrees<->counts maps (see Geometry).
cfg.geometry.yaw.counts_per_deg = getDouble(kv, "Motor.yaw_counts_per_deg", cfg.geometry.yaw.counts_per_deg);
cfg.geometry.yaw.zero_count = getLong(kv, "Motor.yaw_zero_count", cfg.geometry.yaw.zero_count);
cfg.geometry.yaw.min_deg = getDouble(kv, "Motor.yaw_min_deg", cfg.geometry.yaw.min_deg);
cfg.geometry.yaw.max_deg = getDouble(kv, "Motor.yaw_max_deg", cfg.geometry.yaw.max_deg);
cfg.geometry.pitch.counts_per_deg = getDouble(kv, "Motor.pitch_counts_per_deg", cfg.geometry.pitch.counts_per_deg);
cfg.geometry.pitch.zero_count = getLong(kv, "Motor.pitch_zero_count", cfg.geometry.pitch.zero_count);
cfg.geometry.pitch.min_deg = getDouble(kv, "Motor.pitch_min_deg", cfg.geometry.pitch.min_deg);
cfg.geometry.pitch.max_deg = getDouble(kv, "Motor.pitch_max_deg", cfg.geometry.pitch.max_deg);
{
std::string hz = get(kv, "Motor.yaw_home_zero", "off");
for (auto& ch : hz) ch = static_cast<char>(std::tolower((unsigned char)ch));
cfg.yaw_home_zero = hz == "high" ? YawHomeZero::High
: hz == "low" ? YawHomeZero::Low
: YawHomeZero::Off;
}
cfg.enc_error_warn_counts = getLong(kv, "Motor.enc_error_warn_counts",
cfg.enc_error_warn_counts);
// [Scan]: scan-grid source (explicit CSV file, else generated).
cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file);
cfg.scan.yaw_intervals = getInt(kv, "Scan.yaw_intervals", cfg.scan.yaw_intervals);
cfg.scan.yaw_min_deg = getDouble(kv, "Scan.yaw_min_deg", cfg.scan.yaw_min_deg);
cfg.scan.yaw_max_deg = getDouble(kv, "Scan.yaw_max_deg", cfg.scan.yaw_max_deg);
cfg.scan.pitch_levels = get(kv, "Scan.pitch_levels", cfg.scan.pitch_levels);
// ---- validation ----
if (cfg.general.image_interval <= 0)
throw std::runtime_error("General.image_interval must be > 0 (got " +
std::to_string(cfg.general.image_interval) + ")");
if (cfg.serial.baud == 0)
throw std::runtime_error("Serial.baud must be > 0");
return cfg;
}
AppConfig ConfigLoader::loadFromFile(const std::string& path) {
std::map<std::string, std::string> kv;
int rc = ini_parse(path.c_str(), iniHandler, &kv);
if (rc < 0) throw std::runtime_error("Cannot read config file: " + path);
if (rc > 0)
throw std::runtime_error("Parse error in config file " + path + " at line " +
std::to_string(rc));
AppConfig cfg = fromMap(kv);
// Anchor a relative scan grid_file to the config file's directory so it resolves
// no matter which working directory the process is launched from (the config
// itself is found via a search path that may be unrelated to the cwd).
if (!cfg.scan.grid_file.empty()) {
std::filesystem::path gf(paths::expandUser(cfg.scan.grid_file));
if (gf.is_relative()) gf = std::filesystem::path(path).parent_path() / gf;
cfg.scan.grid_file = gf.lexically_normal().string();
}
// Same for the camera mode directory.
{
std::filesystem::path md(paths::expandUser(cfg.camera.mode_dir));
if (md.is_relative()) md = std::filesystem::path(path).parent_path() / md;
cfg.camera.mode_dir = md.lexically_normal().string();
}
return cfg;
}
namespace {
std::string trimmed(const std::string& s) {
const char* ws = " \t\r\n";
size_t a = s.find_first_not_of(ws);
if (a == std::string::npos) return "";
size_t b = s.find_last_not_of(ws);
return s.substr(a, b - a + 1);
}
} // namespace
std::string updateIniSectionKeys(const std::string& contents, const std::string& section,
const std::vector<std::pair<std::string, std::string>>& kv) {
std::vector<bool> written(kv.size(), false);
std::istringstream in(contents);
std::ostringstream out;
std::string line, cur;
while (std::getline(in, line)) {
const std::string t = trimmed(line);
if (t.size() >= 2 && t.front() == '[' && t.back() == ']') {
cur = trimmed(t.substr(1, t.size() - 2));
} else if (cur == section && !t.empty() && t[0] != ';' && t[0] != '#') {
const size_t eq = line.find('=');
if (eq != std::string::npos) {
const std::string key = trimmed(line.substr(0, eq));
for (size_t i = 0; i < kv.size(); ++i) {
if (!written[i] && key == kv[i].first) {
line = kv[i].first + " = " + kv[i].second;
written[i] = true;
break;
}
}
}
}
out << line << "\n";
}
// Any keys not present in the section get appended in a fresh [section] block
// (inih merges duplicate sections on read).
bool missing = false;
for (bool w : written) missing = missing || !w;
if (missing) {
out << "\n[" << section << "]\n";
for (size_t i = 0; i < kv.size(); ++i)
if (!written[i]) out << kv[i].first << " = " << kv[i].second << "\n";
}
return out.str();
}
bool saveMotorCalibration(const std::string& path, const Geometry& geo) {
if (path.empty()) return false;
std::ifstream f(path);
if (!f) return false;
std::stringstream buf;
buf << f.rdbuf();
f.close();
auto num = [](double v) {
char b[32];
std::snprintf(b, sizeof(b), "%.10g", v);
return std::string(b);
};
// Persist the full per-axis map, not just the fitted counts_per_deg/zero_count:
// min_deg/max_deg are part of the geometry and consumers (e.g. travel-range
// estimates) rely on them, so writing them keeps the [Motor] block complete and
// self-consistent rather than depending on pre-existing lines.
const std::vector<std::pair<std::string, std::string>> kv = {
{"yaw_counts_per_deg", num(geo.yaw.counts_per_deg)},
{"yaw_zero_count", std::to_string(geo.yaw.zero_count)},
{"yaw_min_deg", num(geo.yaw.min_deg)},
{"yaw_max_deg", num(geo.yaw.max_deg)},
{"pitch_counts_per_deg", num(geo.pitch.counts_per_deg)},
{"pitch_zero_count", std::to_string(geo.pitch.zero_count)},
{"pitch_min_deg", num(geo.pitch.min_deg)},
{"pitch_max_deg", num(geo.pitch.max_deg)},
};
const std::string updated = updateIniSectionKeys(buf.str(), "Motor", kv);
std::ofstream out(path, std::ios::trunc);
if (!out) return false;
out << updated;
return static_cast<bool>(out);
}
} // namespace fgc