#include "fgc/Config.h" #include "fgc/Logger.h" #include "fgc/Paths.h" #include #include #include #include #include #include #include #include #include extern "C" { #include "ini.h" } namespace fgc { namespace fs = std::filesystem; namespace { std::string get(const std::map& 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& 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& 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& 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& 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& 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 + "'"); } // "yaw, pitch" -> {"yaw","pitch"}. Lowercased and trimmed; empty entries dropped, // so trailing commas and stray spacing are not errors an operator has to hunt. std::vector splitList(const std::string& s) { std::vector out; std::string cur; auto flush = [&] { size_t a = cur.find_first_not_of(" \t"); if (a == std::string::npos) { cur.clear(); return; } size_t b = cur.find_last_not_of(" \t"); std::string t = cur.substr(a, b - a + 1); for (char& c : t) c = static_cast(std::tolower((unsigned char)c)); if (!t.empty()) out.push_back(t); cur.clear(); }; for (char c : s) { if (c == ',') flush(); else cur += c; } flush(); return out; } std::string lowered(std::string s) { for (char& c : s) c = static_cast(std::tolower((unsigned char)c)); return s; } 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*>(user); (*kv)[std::string(section) + "." + std::string(name)] = value ? value : ""; return 1; } } // namespace CameraProfileTiming CameraSpec::timingFor(const std::string& profile_name) const { auto it = timing.find(profile_name); return it != timing.end() ? it->second : CameraProfileTiming{}; } std::vector CameraConfig::active() const { std::vector out; for (const auto& c : cameras) if (c.enabled) out.push_back(&c); return out; } int CameraConfig::maxSettleWaitMs() const { int worst = 0; for (const auto* c : active()) worst = std::max(worst, c->timingFor(c->profile).settle_wait_ms); return worst > 0 ? worst : CameraProfileTiming{}.settle_wait_ms; } int CameraConfig::maxFrameTimeoutMs() const { int worst = 0; for (const auto* c : active()) worst = std::max(worst, c->timingFor(c->profile).frame_timeout_ms); return worst > 0 ? worst : CameraProfileTiming{}.frame_timeout_ms; } 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(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(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 TestProfile::getLongList(const std::string& key) const { std::vector 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& 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); // [Board] / [Hardware]: normally from hardware.ini, but read from the same // merged map, so a config.ini carrying them still works during migration. cfg.board.model = get(kv, "Board.model", cfg.board.model); cfg.board.firmware_env = get(kv, "Board.firmware_env", cfg.board.firmware_env); cfg.hardware.axes = splitList(get(kv, "Hardware.axes")); cfg.hardware.cameras = splitList(get(kv, "Hardware.cameras")); cfg.hardware.imu = lowered(get(kv, "Hardware.imu")); cfg.hardware.env = lowered(get(kv, "Hardware.env")); for (const auto& a : cfg.hardware.axes) if (a != "yaw" && a != "pitch") throw std::runtime_error("Hardware.axes: unknown axis '" + a + "' (expected yaw and/or pitch)"); 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(getInt(kv, "Serial.baud", cfg.serial.baud)); // ---- [Camera] shared defaults, then one [Camera.] block per camera ---- 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); // Cameras are discovered from the key names rather than a list, so there is // no registry to keep in sync: "Camera.rgb.id" declares a camera called rgb. // A third dotted segment ("Camera.rgb.standard.settle_wait_ms") is per-profile // timing, so only two-segment suffixes name a camera. std::vector names; // in first-seen order for (const auto& [key, unused] : kv) { (void)unused; if (key.rfind("Camera.", 0) != 0) continue; const std::string rest = key.substr(7); const auto dot = rest.find('.'); if (dot == std::string::npos) continue; // [Camera] itself const std::string name = rest.substr(0, dot); if (rest.find('.', dot + 1) != std::string::npos) { // per-profile timing if (std::find(names.begin(), names.end(), name) == names.end()) names.push_back(name); continue; } if (std::find(names.begin(), names.end(), name) == names.end()) names.push_back(name); } cfg.camera.cameras.clear(); for (const auto& name : names) { const std::string pre = "Camera." + name + "."; CameraSpec c; c.name = name; c.enabled = getBool(kv, pre + "enabled", true); c.driver = get(kv, pre + "driver", c.driver); c.id = get(kv, pre + "id"); c.label = get(kv, pre + "label", name); c.profile_dir = get(kv, pre + "profile_dir"); c.profile = get(kv, pre + "profile", c.profile); c.rotation_deg = getInt(kv, pre + "rotation_deg", c.rotation_deg); c.jxl_distance = getDouble(kv, pre + "jxl_distance", cfg.camera.jxl_distance); c.jxl_effort = getInt(kv, pre + "jxl_effort", cfg.camera.jxl_effort); // Per-profile timing: Camera...settle_wait_ms for (const auto& [key, val] : kv) { (void)val; if (key.rfind(pre, 0) != 0) continue; const std::string rest = key.substr(pre.size()); const auto dot = rest.find('.'); if (dot == std::string::npos) continue; const std::string profile = rest.substr(0, dot); const std::string field = rest.substr(dot + 1); CameraProfileTiming& t = c.timing[profile]; if (field == "settle_wait_ms") t.settle_wait_ms = getInt(kv, key, t.settle_wait_ms); else if (field == "frame_timeout_ms") t.frame_timeout_ms = getInt(kv, key, t.frame_timeout_ms); else throw std::runtime_error("unknown key '" + key + "' (expected " + pre + profile + ".settle_wait_ms or .frame_timeout_ms)"); } if (c.driver != "vimba") throw std::runtime_error("Camera." + name + ".driver must be 'vimba' (got '" + c.driver + "'); no other backend is implemented yet"); if (c.enabled && c.id.empty()) throw std::runtime_error("Camera." + name + ".id is required (USB DEV_ id or GigE IP)"); if (c.profile_dir.empty()) throw std::runtime_error("Camera." + name + ".profile_dir is required"); if (c.jxl_distance < 0.0) throw std::runtime_error("Camera." + name + ".jxl_distance must be >= 0 (0 = lossless)"); if (c.jxl_effort < 1 || c.jxl_effort > 9) throw std::runtime_error("Camera." + name + ".jxl_effort must be in 1..9"); if (c.rotation_deg != 0 && c.rotation_deg != 90 && c.rotation_deg != 180 && c.rotation_deg != 270) throw std::runtime_error("Camera." + name + ".rotation_deg must be 0, 90, 180 or 270"); for (const auto& [pname, t] : c.timing) { if (t.settle_wait_ms < 0 || t.frame_timeout_ms < 0) throw std::runtime_error("Camera." + name + "." + pname + " timings must be >= 0"); } cfg.camera.cameras.push_back(std::move(c)); } 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(getInt(kv, "IMU.baud", cfg.imu.baud)); // [IMU] x/y/z: how the unit is bolted on, in degrees about forward/right/down. // A value outside +/-360 is a typo far more often than an intent, and one that // is accepted silently is invisible afterwards - the readings just quietly // describe the wrong axes. cfg.imu.mount.x_deg = getDouble(kv, "IMU.x", cfg.imu.mount.x_deg); cfg.imu.mount.y_deg = getDouble(kv, "IMU.y", cfg.imu.mount.y_deg); cfg.imu.mount.z_deg = getDouble(kv, "IMU.z", cfg.imu.mount.z_deg); for (const auto& [axis, value] : {std::pair{"x", cfg.imu.mount.x_deg}, std::pair{"y", cfg.imu.mount.y_deg}, std::pair{"z", cfg.imu.mount.z_deg}}) { if (!std::isfinite(value) || std::abs(value) > 360.0) throw std::runtime_error(std::string("IMU.") + axis + " must be a rotation in degrees within +/-360"); } cfg.env.i2c_device = get(kv, "Env.i2c_device", cfg.env.i2c_device); cfg.env.i2c_addr = static_cast( getIntAutoBase(kv, "Env.i2c_addr", static_cast(cfg.env.i2c_addr))); cfg.env.period_ms = getInt(kv, "Env.period_ms", cfg.env.period_ms); // [Test] + [TestProfile.]: discover profiles by scanning the flattened // keys. inih gives us "TestProfile.." => 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]: the degrees<->counts maps, anchored on the homed mechanical centre // (see Geometry). min_deg/max_deg are NOT configured - they are derived from // the firmware's homed soft limits, the only place they can be correct. cfg.geometry.yaw.counts_per_deg = getDouble(kv, "Motor.yaw_counts_per_deg", cfg.geometry.yaw.counts_per_deg); cfg.geometry.yaw.center_count = getLong(kv, "Motor.yaw_center_count", cfg.geometry.yaw.center_count); cfg.geometry.yaw.deg_at_center = getDouble(kv, "Motor.yaw_heading_at_center", cfg.geometry.yaw.deg_at_center); cfg.geometry.pitch.counts_per_deg = getDouble(kv, "Motor.pitch_counts_per_deg", cfg.geometry.pitch.counts_per_deg); cfg.geometry.pitch.center_count = getLong(kv, "Motor.pitch_center_count", cfg.geometry.pitch.center_count); cfg.geometry.pitch.deg_at_center = getDouble(kv, "Motor.pitch_angle_at_center", cfg.geometry.pitch.deg_at_center); cfg.yaw_soft_margin_counts = getLong(kv, "Motor.yaw_soft_margin_counts", cfg.yaw_soft_margin_counts); cfg.pitch_soft_margin_counts = getLong(kv, "Motor.pitch_soft_margin_counts", cfg.pitch_soft_margin_counts); 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"); // A zero scale makes every degree map to the centre and every count read back // as the centre - the axis would look calibrated and point nowhere. if (std::abs(cfg.geometry.yaw.counts_per_deg) < 1e-9) throw std::runtime_error("Motor.yaw_counts_per_deg must be non-zero"); if (std::abs(cfg.geometry.pitch.counts_per_deg) < 1e-9) throw std::runtime_error("Motor.pitch_counts_per_deg must be non-zero"); // The centre count exists so the whole working range stays positive; a // non-positive one defeats the point and can drive the firmware negative. if (cfg.geometry.yaw.center_count <= 0) throw std::runtime_error("Motor.yaw_center_count must be > 0"); if (cfg.geometry.pitch.center_count <= 0) throw std::runtime_error("Motor.pitch_center_count must be > 0"); if (cfg.yaw_soft_margin_counts < 0) throw std::runtime_error("Motor.yaw_soft_margin_counts must be >= 0"); if (cfg.pitch_soft_margin_counts < 0) throw std::runtime_error("Motor.pitch_soft_margin_counts must be >= 0"); // Keys from the pre-centre-anchoring layout. Silently ignoring them would // leave an operator convinced they had configured a heading that is in fact // coming from somewhere else entirely - the exact failure this layout removes. static const char* kRemovedMotorKeys[] = { "Motor.yaw_zero_count", "Motor.pitch_zero_count", "Motor.yaw_min_deg", "Motor.yaw_max_deg", "Motor.pitch_min_deg", "Motor.pitch_max_deg", "Motor.yaw_home_zero", }; std::string stale; for (const char* k : kRemovedMotorKeys) if (kv.count(k)) stale += (stale.empty() ? "" : ", ") + std::string(k + 6); if (!stale.empty()) LOG_WARN << "[Motor] ignoring keys removed in the centre-anchored layout: " << stale << " -- travel limits are now derived from homing, and the heading " "origin comes from yaw_heading_at_center (set it with " "'gimbal heading setup')"; return cfg; } bool HardwareConfig::hasAxis(const std::string& name) const { // An undeclared rig answers true for everything - see the header. if (axes.empty()) return true; return std::find(axes.begin(), axes.end(), name) != axes.end(); } std::string HardwareConfig::axesLabel() const { std::string s; for (const auto& a : axes) s += (s.empty() ? "" : ", ") + a; return s; } AppConfig ConfigLoader::loadFromFile(const std::string& path, const std::string& hardware_path) { std::map 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)); // hardware.ini is layered ON TOP, so the assembly description wins over a // config.ini that still carries the same keys from before the split. Any such // leftover is named once, because it is a migration leftover to delete rather // than a setting that is quietly doing nothing. std::vector shadowed; if (!hardware_path.empty()) { std::map hw; int hrc = ini_parse(hardware_path.c_str(), iniHandler, &hw); if (hrc < 0) throw std::runtime_error("Cannot read hardware file: " + hardware_path); if (hrc > 0) throw std::runtime_error("Parse error in hardware file " + hardware_path + " at line " + std::to_string(hrc)); for (const auto& [k, v] : hw) { if (auto it = kv.find(k); it != kv.end() && it->second != v) shadowed.push_back(k); kv[k] = v; } } AppConfig cfg = fromMap(kv); if (!hardware_path.empty()) { cfg.hardware.declared = true; cfg.hardware.source_path = hardware_path; LOG_INFO << "hardware: " << hardware_path << (cfg.board.model.empty() ? "" : " board " + cfg.board.model) << (cfg.hardware.axes.empty() ? "" : " axes " + cfg.hardware.axesLabel()); for (const auto& k : shadowed) LOG_WARN << "config.ini still sets " << k << ", which hardware.ini overrides -- delete it from config.ini"; } // 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 each camera's profile directory. for (auto& c : cfg.camera.cameras) { std::filesystem::path pd(paths::expandUser(c.profile_dir)); if (pd.is_relative()) pd = std::filesystem::path(path).parent_path() / pd; c.profile_dir = pd.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); } // Marker for a superseded value, kept so a calibration can be reverted by hand. // It is a comment, so inih ignores it on the next read, but it is distinctive // enough for updateIniSectionKeys to find its own earlier markers and replace // them rather than pile them up. const std::string kPrevMarker = ";prev "; // The value inih would actually parse from the right-hand side of `key = value`: // everything up to an inline comment, which is a ';' PRECEDED BY WHITESPACE (see // ini_find_chars_or_comment in ini.c, with INI_ALLOW_INLINE_COMMENTS=1 and // INI_INLINE_COMMENT_PREFIXES=";"). Mirroring that rule matters here: "did this // value change?" has to compare what the program read, not the raw line, or a // key carrying a trailing comment would look changed on every save. std::string iniValueOf(const std::string& rhs) { bool was_space = false; for (size_t i = 0; i < rhs.size(); ++i) { if (was_space && rhs[i] == ';') return trimmed(rhs.substr(0, i)); was_space = std::isspace(static_cast(rhs[i])) != 0; } return trimmed(rhs); } // If `t` (already trimmed) is a ";prev = ..." marker, return , else "". std::string prevMarkerKey(const std::string& t) { const size_t n = kPrevMarker.size(); if (t.compare(0, n, kPrevMarker) != 0) return ""; const size_t eq = t.find('=', n); if (eq == std::string::npos) return ""; return trimmed(t.substr(n, eq - n)); } } // namespace std::string updateIniSectionKeys(const std::string& contents, const std::string& section, const std::vector>& kv, const std::string& provenance) { // Which keys are actually changing, and what they hold now. // // Only a changing key gets a ";prev" line, and only its marker is replaced: // a key whose value is unchanged keeps the history it has, because that still // names the value before the current one. This also keeps the file from // growing on every save - saveMotorGeometry rewrites all eight keys each time, // and without this filter seven of them would be noise. // // Computed in a first pass because a key's ";prev" marker sits ABOVE its value // line, so the writing pass meets the marker before it has seen whether the // value differs. std::vector changed(kv.size(), false); std::vector old_value(kv.size()); { std::istringstream scan(contents); std::string line, cur; std::vector seen(kv.size(), false); while (std::getline(scan, line)) { const std::string t = trimmed(line); if (t.size() >= 2 && t.front() == '[' && t.back() == ']') { cur = trimmed(t.substr(1, t.size() - 2)); continue; } if (cur != section || t.empty() || t[0] == ';' || t[0] == '#') continue; const size_t eq = line.find('='); if (eq == std::string::npos) continue; const std::string key = trimmed(line.substr(0, eq)); for (size_t i = 0; i < kv.size(); ++i) { if (!seen[i] && key == kv[i].first) { seen[i] = true; old_value[i] = iniValueOf(line.substr(eq + 1)); changed[i] = old_value[i] != kv[i].second; break; } } } } const bool keep_history = !provenance.empty(); std::vector 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) { // An earlier ";prev" for a key that is changing now is dropped here // and re-emitted below, so exactly one generation is ever kept. const std::string pk = prevMarkerKey(t); if (!pk.empty()) { bool supersede = false; for (size_t i = 0; i < kv.size(); ++i) if (pk == kv[i].first && changed[i]) { supersede = true; break; } if (supersede) continue; } else if (!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) { if (keep_history && changed[i]) out << kPrevMarker << kv[i].first << " = " << old_value[i] << " ; " << provenance << "\n"; 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 saveMotorGeometry(const std::string& path, const Geometry& geo, long yaw_margin, long pitch_margin, const std::string& provenance) { 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); }; // The whole [Motor] block, so it stays complete and self-consistent rather // than depending on which lines happened to be there already. min_deg/max_deg // are deliberately absent: they are derived from homing, not configured. const std::vector> kv = { {"yaw_counts_per_deg", num(geo.yaw.counts_per_deg)}, {"yaw_center_count", std::to_string(geo.yaw.center_count)}, {"yaw_heading_at_center", num(geo.yaw.deg_at_center)}, {"yaw_soft_margin_counts", std::to_string(yaw_margin)}, {"pitch_counts_per_deg", num(geo.pitch.counts_per_deg)}, {"pitch_center_count", std::to_string(geo.pitch.center_count)}, {"pitch_angle_at_center", num(geo.pitch.deg_at_center)}, {"pitch_soft_margin_counts", std::to_string(pitch_margin)}, }; const std::string updated = updateIniSectionKeys(buf.str(), "Motor", kv, provenance); // Write a sibling temp file and rename over the original: a truncating write // that dies partway leaves config.ini destroyed, and three commands rewrite // this file while the program is running. std::error_code ec; const fs::path target(path); const fs::path tmp = target.parent_path() / (target.filename().string() + ".tmp"); { std::ofstream out(tmp, std::ios::trunc | std::ios::binary); if (!out) return false; out << updated; out.flush(); if (!out) { fs::remove(tmp, ec); return false; } } fs::rename(tmp, target, ec); if (ec) { fs::remove(tmp, ec); return false; } return true; } } // namespace fgc