#pragma once #include "fgc/ImuMount.h" #include "fgc/Geometry.h" #include #include #include #include namespace fgc { // Typed application configuration. Replaces the ad-hoc std::map // lookups that were scattered through main.cpp. struct GeneralConfig { std::string tower_name = "Unnamed"; int image_interval = 5; // seconds between captures bool debug = false; // print motor telemetry each loop tick }; // [Board]: which computer this is. Assembly data - it changes only when the // machine is physically replaced, so it belongs in hardware.ini, not config.ini. struct BoardConfig { std::string model; // e.g. "lattepanda-3-delta", "lattepanda-iota" std::string firmware_env; // PlatformIO env for the motor MCU ("leonardo", ...) }; // [Hardware]: what is actually fitted to THIS rig. // // The point of declaring it is that the rest of the program degrades gracefully // when something is missing - TestRunner skips the pitch and IMU leaves, CamEvent // falls back to encoder pitch, CaptureScheduler sends the single-axis MOVE form - // which is right for a rig that genuinely lacks them, and indistinguishable from a // FAILURE on a rig that does not. Declaring what was assembled is what lets // startup tell those two apart. struct HardwareConfig { // Empty means "not declared": nothing was stated, so nothing is reconciled. // This is the state every existing tower is in until it gets a hardware.ini. std::vector axes; // e.g. {"yaw", "pitch"} std::string imu; // sensor model, or "none" std::string env; // sensor model, or "none" std::vector cameras; // the [Camera.] blocks expected to exist // Was a hardware.ini actually read? False on every tower that has not been // migrated yet, and the switch that keeps this change invisible on rollout. bool declared = false; // Where it came from, for logging and for `test hardware` to cite. std::string source_path; // True when `name` is listed in axes. An UNDECLARED rig answers true for // everything: with nothing stated there is nothing to contradict, and callers // must keep behaving exactly as they did before. bool hasAxis(const std::string& name) const; // "yaw, pitch" - for log lines and test output. std::string axesLabel() const; }; // [Motor.] - what this tower's drivetrain IS, one block per axis. These // change only when the motor, gearbox or driver board changes, so they belong in // hardware.ini beside the rest of the assembly description. // // Currents are milliamps RMS per phase, NOT register values: the conversion needs // the sense resistor fitted to this particular driver board, so the host owns it // and sends the firmware the registers it computed (see TmcCurrent.h). struct AxisDrivetrainConfig { bool declared = false; // any [Motor.] key present at all double r_sense_ohm = 0.0; // sense resistor on this axis's TMC board double run_current_ma = 0.0; // 0 = undeclared -> nothing is sent double homing_current_ma = 0.0; // defaults to run_current_ma // Mechanical angle between the hard endstops. A nameplate fact, not a // calibration: homing measures the travel in counts, and dividing by this // yields [Motor] _counts_per_deg, which lives in config.ini and is // saved with history. Keeping them in different files AND different keys is // deliberate - hardware.ini is merged over config.ini and wins, so a shared // key would silently discard every saved calibration. double travel_deg = 0.0; // 0 = undeclared -> nothing is derived // Production move speed, as a PHYSICAL quantity. VMAX is a register value // whose meaning depends on the gear ratio - 30000 is 21.8 deg/s on the Delta // fleet and 6.0 deg/s on the IOTA - so declaring it in register units makes // the number unportable and, in practice, wrong when copied between towers. // The host converts through counts_per_deg and sends SPEED; see AxisSpeed.h. double scan_speed_deg_s = 8.0; // kDefaultScanSpeedDegPerSec // True when the homing current is genuinely lower than the run current, i.e. // when the reduction is load-bearing and homing without it risks the rig. bool reducesForHoming() const { return run_current_ma > 0.0 && homing_current_ma > 0.0 && homing_current_ma < run_current_ma; } }; struct DrivetrainConfig { AxisDrivetrainConfig yaw; AxisDrivetrainConfig pitch; }; struct NetworkConfig { std::string broker_ip = "127.0.0.1"; // MQTT broker host/IP, or a full tcp://... URI int mqtt_port = 1883; // ignored when broker_ip carries its own port std::string mqtt_user; // see secrets note below std::string mqtt_pw; // Paho serverURI for broker_ip + mqtt_port, e.g. "tcp://10.0.0.5:1883". // // Built here rather than handing Paho a bare address and relying on its // default port, so the port is explicit, configurable and visible in the log. // Two precedence rules, both to keep existing configs working: // - broker_ip containing "://" is a URI the operator owns: returned verbatim, // mqtt_port ignored (it may already have a port, or a ws:// path to break). // - broker_ip already carrying "host:port" keeps that port; mqtt_port ignored. // A bare IPv6 address is bracketed, or Paho's rightmost-colon parse would read // the last hextet as the port. std::string brokerUri() const; }; struct SerialConfig { std::string device = "/dev/ttyACM0"; unsigned int baud = 115200; }; // Per-profile program behaviour, from [Camera..]. These are not // camera settings - the camera's own settings live entirely in the profile XML - // but timing the program must observe around them. struct CameraProfileTiming { int settle_wait_ms = 1000; // hold after standstill before keeping a frame int frame_timeout_ms = 2000; // give up on the waypoint after this }; // One camera, from a [Camera.] block. Nothing is implied by position: the // block names the camera, `label` names its output, `driver` picks the backend. struct CameraSpec { std::string name; // the block suffix, e.g. "rgb" bool enabled = true; std::string driver = "vimba"; // only "vimba" is implemented std::string id; // USB DEV_ id or GigE IP std::string label; // output subdir + CamEvent "cam"; defaults to name std::string profile_dir; // relative => resolved next to config.ini std::string profile = "standard"; // which profile to start in int rotation_deg = 0; // clockwise; 0/90/180/270 // Encoding, defaulted from [Camera] and overridable per camera. double jxl_distance = 0.8; int jxl_effort = 4; std::map timing; // by profile name // Timing for `profile`, or the defaults when the block does not name it. CameraProfileTiming timingFor(const std::string& profile_name) const; }; struct CameraConfig { std::vector cameras; // in config order; empty is a config error // [Camera] defaults, inherited by each block unless it overrides them. double jxl_distance = 0.8; int jxl_effort = 4; // Enabled cameras only. std::vector active() const; // The gimbal must stand still long enough for EVERY active camera, so the // scheduler takes the longest wait rather than the first camera's. int maxSettleWaitMs() const; int maxFrameTimeoutMs() const; }; struct PathsConfig { // Where captured .jxl images are written. Supports leading ~ and $ENV // expansion. Empty => resolved to a sensible default at load time. std::string output_dir; }; struct FeaturesConfig { bool enable_mqtt = true; bool enable_camera = true; bool enable_serial = true; bool enable_imu = false; // Xsens MTi orientation/IMU (off by default) bool mock_camera = false; // use a simulated camera instead of Vimba X bool mock_serial = false; // use a simulated motor controller bool mock_imu = false; // use a simulated IMU instead of the MTi bool enable_env = false; // ambient temp/humidity sensor (off by default) bool mock_env = false; // use a simulated env sensor instead of the SHT41 // Refuse to start when hardware.ini declares a subsystem this rig does not // have (HardwarePresence.h). Off by default so that gaining a hardware.ini // cannot change how a tower behaves: it is turned on per tower once that // file is known good, and the fleet-wide flip is a config change rather than // a release. --degraded overrides it for field triage. bool strict_presence = false; }; // Where a sensor's on/off state came from. [Hardware] imu / env DECLARE what is // fitted; [Features] enable_imu / enable_env are the older flags that said the // same thing a second time, and could disagree with it. enum class SensorEnableSource { Declaration, // [Hardware] imu / env said so - the new, single source Flag, // no declaration, but the legacy flag was written explicitly Default, // neither: the built-in default }; struct SensorEnable { bool enabled = false; SensorEnableSource source = SensorEnableSource::Default; // The legacy flag was present AND contradicted the declaration. The // declaration wins; this exists so the stale key can be named and deleted // rather than silently ignored. bool conflict = false; }; // Resolve one sensor's enablement. // // The declaration wins wherever it exists, which is what retires the flag: a rig // that says `imu = mti-630` needs no second key agreeing with it, and a rig that // says `imu = none` cannot be contradicted into enabling one. With nothing // declared - every tower until it is migrated - the flag governs exactly as // before. SensorEnable resolveSensorEnable(const std::string& declared, bool flag_value, bool flag_present); // [IMU]: Xsens MTi connected over the LattePanda's RS-232 UART (a hardware // /dev/ttyS* node, stable across reboots — not a USB device). struct ImuConfig { std::string device = ""; // e.g. /dev/ttyS4; empty => required when enabled unsigned int baud = 115200; // How the unit is bolted to the gimbal, as [IMU] x/y/z. Every reading the // program produces is rotated into the gimbal's NED body frame by this, so // nothing downstream ever sees the sensor frame. See ImuMount.h for the // convention; the default is the identity, which changes nothing. ImuMount mount; }; // [Env]: ambient temperature/humidity sensor (SHT41) on the LattePanda's own // native I2C bus — NOT the Arduino/motor serial link. Bus number varies by // board; confirm with `i2cdetect -y N` (expect the SHT41 at 0x44). struct EnvConfig { std::string i2c_device = "/dev/i2c-1"; // empty => required when enabled unsigned int i2c_addr = 0x44; int period_ms = 2000; // sample interval }; struct LoggingConfig { std::string level; // trace|debug|info|warn|error|off; empty => default (CLI overrides) std::string trace; // verbatim wire-trace categories: serial,mqtt,camera,control,all,none }; struct UiConfig { bool enable_tui = false; // false => headless line console (default); CLI --tui/--no-tui override }; // [Scan]: source of the capture scan grid (the (yaw,pitch) waypoints the // auto-sweep steps through). If grid_file is set, the CSV is loaded verbatim; // otherwise a grid is generated from the parameters below (see ScanGrid). struct ScanConfig { std::string grid_file; // path to an editable yaw_deg,pitch_deg CSV; empty => generate int yaw_intervals = 56; // generated: yaw positions across [yaw_min_deg, yaw_max_deg] double yaw_min_deg = -90.0; double yaw_max_deg = 90.0; std::string pitch_levels = "0"; // generated: comma list of pitch elevations (deg) }; // [Test] / [TestProfile.]: the hardware self-test command. A profile is a // named bundle of repetition counts, sampling windows and pass thresholds; the // modules read the values they care about by key (open map so new tests can add // knobs without touching the loader). struct TestProfile { std::string name; std::map params; // numeric knobs: reps, *_window_ms, *_max_* std::map text; // raw values incl. comma lists (e.g. step sizes) double get(const std::string& key, double fallback) const; int geti(const std::string& key, int fallback) const; bool getBool(const std::string& key, bool fallback) const; std::string gets(const std::string& key, const std::string& fallback = "") const; std::vector getLongList(const std::string& key) const; // parse "200,500,1000" }; struct TestConfig { std::string default_profile = "standard"; std::string baseline_file; // empty => /test_baseline.txt std::map profiles; // `test hardware camera` USB preconditions. Both are machine facts rather // than tuning: the threshold is what frame delivery needs (known-issues #19), // and the LPM node's path differs per board, so it cannot be guessed. Left // empty, the LPM check reports "not checked" rather than passing. int usbfs_min_mb = 1000; std::string usb3_lpm_path; const TestProfile* find(const std::string& name) const; // nullptr if absent }; struct AppConfig { GeneralConfig general; NetworkConfig network; SerialConfig serial; CameraConfig camera; PathsConfig paths; BoardConfig board; // [Board] from hardware.ini HardwareConfig hardware; // [Hardware] from hardware.ini FeaturesConfig features; LoggingConfig logging; UiConfig ui; // [UI] terminal dashboard toggle Geometry geometry; // [Motor] degrees<->counts maps (yaw + pitch) DrivetrainConfig drivetrain; // [Motor.] from hardware.ini // [Motor] how long a full two-end homing may take before the host calls it // failed. A property of the drivetrain, not of this program: a high gear // ratio searching at a gentle speed takes minutes. int homing_timeout_s = 65; // [Motor] clearance kept from each hard endstop, in counts. Sent to the // firmware (MARGIN), which enforces it; kept in counts rather than degrees // because it must apply at homing time, before any degree map is trusted. long yaw_soft_margin_counts = 5000; long pitch_soft_margin_counts = 5000; long enc_error_warn_counts = 400; // [Motor] live encoder-error WARN (0=off) ScanConfig scan; // [Scan] grid source ImuConfig imu; // [IMU] Xsens MTi serial device EnvConfig env; // [Env] SHT41 ambient sensor, LattePanda I2C TestConfig test; // [Test] hardware self-test profiles // Capture rate in images/second (derived from general.image_interval). double image_rate() const; }; // Loads and validates configuration. // // Secrets: mqtt_user / mqtt_pw are taken from the environment variables // FGC_MQTT_USER / FGC_MQTT_PW when present; the INI values are a fallback. class ConfigLoader { public: // Reads the INI file at `path` (via the bundled inih parser), maps it into // a typed AppConfig, applies environment overrides, and validates it. // Throws std::runtime_error with a clear message on failure. // // `hardware_path`, when given, is read AFTER the config and its keys win. It // carries the assembly-time description of the machine ([Board], [Hardware], // and the device bindings), which outlives any amount of operational tuning. // Passing "" keeps the historical behaviour exactly: everything comes from // config.ini and `hardware.declared` stays false. static AppConfig loadFromFile(const std::string& path, const std::string& hardware_path = ""); // Same mapping/validation logic, but from an already-parsed key->value map // ("Section.name" => value). Exposed for unit testing without file IO. static AppConfig fromMap(const std::map& kv); }; // Return `contents` with the given `key = value` pairs set under `[section]`: // existing keys in that section are replaced in place (comments/order preserved), // and any missing ones are appended in a `[section]` block at the end. Pure (no // I/O) so it is unit-testable. // // With a non-empty `provenance`, a key whose value actually CHANGES also gets its // previous value kept above it as a comment, so a calibration can be reverted by // hand: // // ;prev yaw_heading_at_center = 0.0 ; 2026-09-11T14:32Z, gimbal heading setup // yaw_heading_at_center = 127.4 // // Exactly one generation is kept - an existing `;prev` for a changing key is // replaced, not appended to - and unchanged keys are left completely alone, so // the file grows once and then holds steady. std::string updateIniSectionKeys(const std::string& contents, const std::string& section, const std::vector>& kv, const std::string& provenance = ""); // Persist the live `[Motor]` geometry (counts_per_deg / center_count / // deg_at_center for both axes, plus the endstop margins) back into the INI file // at `path`. Written via a temp file and rename, so an interrupted save cannot // leave a truncated config behind. Returns true on success. // // `provenance` names what produced these values (e.g. "2026-09-11T14:32Z, gimbal // heading setup"); when given, superseded values are kept as `;prev` comments. bool saveMotorGeometry(const std::string& path, const Geometry& geo, long yaw_margin, long pitch_margin, const std::string& provenance = ""); } // namespace fgc