#pragma once #include "fgc/ImuMount.h" #include "fgc/Geometry.h" #include "fgc/OperatingMode.h" #include "fgc/ThermalGuard.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"; double image_interval = 5; // desired seconds from one picture to the next 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"; dht11 = on the motor MCU // "fan": the motor MCU runs an enclosure fan from its DHT11 (legacy towers). // Needs env = dht11. Empty or "none": no fan. std::string cooling; std::vector cameras; // the [Camera.] blocks expected to exist // How much bearing one picture covers, in degrees. Pan mode steps by it: // a span is cut into as many pictures as it takes (OperatingMode.h). double pan_step_deg = 6.0; // Saving images - a property of the computer, not of what the tower does. // Measured (docs/raw12-pipeline-measurements.md): the IOTA keeps up with one // worker x 4 threads, the 3 Delta needs two x 2. int save_workers = 1; // frames encoded at once int jxl_threads = 0; // libjxl threads per frame; 0 = 4 / save_workers int save_queue_max = 4; // frames waiting or saving before the scan holds // 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; // [Pins] - how the MCU is wired to each driver board. The host never sends // these: the firmware is BUILT from them (firmware/tools/gen_wiring.py reads // the same file on the tower). Held here only so `test hardware motor` can // compare them with the pins the running firmware reports it was built with. // -1 = not declared. struct Pins { long yaw_cs = -1, yaw_en = -1, pitch_cs = -1, pitch_en = -1; long spi_sck = -1, spi_mosi = -1, spi_miso = -1; // RP2040 only // Parts on the MCU itself (legacy towers). -1 = not wired. long dht = -1, fan_pwm = -1, fan_tach = -1, spi_idle_cs = -1; bool declared() const { return yaw_cs >= 0 || yaw_en >= 0 || pitch_cs >= 0 || pitch_en >= 0 || spi_sck >= 0; } } pins; // 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 // Speeds for the two motions that are not production moves. Both are also // deg/s, and both are needed BEFORE the first home - so they cannot use the // counts_per_deg that homing derives. preHomeCountsPerDeg() below is the // estimate they convert through. double homing_speed_deg_s = 0.0; // 0 = undeclared -> firmware default stands double center_speed_deg_s = 0.0; // The mechanism's own facts, for the config handshake (FirmwareConfig.h). // The firmware holds none of these once it asks for them. long steps_per_rev = 0; // expected full mechanical travel, in counts long enc_deviation = 0; // |XACTUAL-XENC| that means "position untrustworthy" // Microsteps per encoder count (the TMC's ENC_CONST). 0 = undeclared: the // firmware keeps its default, 12.8. Only a drivetrain whose encoder differs // declares it - staeffelsberg's legacy firmware used 12.5. double enc_const = 0.0; // Wiring: TMC SW_MODE (physical endstop switches on REFL/REFR), and whether // this axis's encoder is aligned to XACTUAL and usable for hold and DIAG. bool wiring_declared = false; // any of the two keys below present unsigned sw_mode = 0; bool has_encoder = true; // counts_per_deg as the DECLARATION implies it, for use before the axis has // been homed. Homing supersedes it by measuring the travel for real; on the // IOTA the two agree to 0.25% (1210778/340 = 3561 against a measured // 3552.87). Returns 0 when it cannot be formed. double preHomeCountsPerDeg() const { if (steps_per_rev <= 0 || travel_deg <= 0.0) return 0.0; return static_cast(steps_per_rev) / travel_deg; } // 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 // How to tell the frame is good to keep, after settle_wait_ms: // fixed keep the newest frame as soon as settle_wait_ms has passed // brightness wait until brightness is in the camera's target band and // steady for 3 frames (SettleRule.h), up to frame_timeout_ms std::string settle_rule = "fixed"; }; // 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; // "vimba" (Allied Vision, colour) or "taris" (InfraTec TarisIR, thermal). std::string driver = "vimba"; std::string id; // vimba: USB DEV_ id or GigE IP; taris: serial number std::string label; // output subdir + CamEvent "cam"; defaults to name std::string profile_dir; // vimba only; relative => resolved next to config.ini // Which profile to start in. For vimba a settings XML in profile_dir; for // taris only the name of a timing block, the camera having no settings file. std::string profile = "standard"; 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; // De-Bayering for raw profiles (BayerRG8/12p): "edge_aware" or "bilinear". std::string demosaic = "edge_aware"; // ---- driver = taris only (TarisCameraSource.h) ---- // Operation (config.ini): int calib_profile = 0; // camera calibration profile; sets the counts' scale double frame_rate_hz = 5.0; // the camera free-runs at this rate, throttled // A frame older than this at capture is not kept. Must stay below the scan's // settle wait, or the frame may have been taken while the gimbal still moved. int max_frame_age_ms = 400; std::string library = "libirbgrab_x86_64-linux.so"; // the SDK, found through the ld cache // Mounting (hardware.ini): the camera's network port, for diagnostics only. std::string interface; bool thermal() const { return driver == "taris"; } 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 { // Colour (vimba) cameras first, then thermal ones, each group in block-name // order: camera 0 is the one the keep rule and the scan pace follow, and // that must be a colour camera. Empty is a config error. std::vector cameras; // [Camera] defaults, inherited by each block unless it overrides them. double jxl_distance = 0.8; int jxl_effort = 4; std::string demosaic = "edge_aware"; // 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 // The simulated camera's frames: false = small 640x480 colour (cheap), true = // the real camera's 5328x3040 12-bit raw, so saving costs the CPU what it // does on a tower (throttling simulations, tools/thermal-sim/). bool mock_camera_full = false; 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 // Make the simulated motor controller run the config handshake: emit NEED // lines at start and reject motion until they are answered. This is how the // host half is exercised on a dev box with no firmware that asks yet. bool mock_needcfg = false; // 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 // Persistent log file. A tower runs detached for days and, in TUI mode, // every line goes to a 500-line on-screen ring buffer and nowhere else - so // the record of what it did dies with the session. This is that record. // // Deliberately INDEPENDENT of `level` above: the dashboard stays readable at // info while the file keeps debug. Wire traces reach the file only when // `trace` turns them on, because the serial trace alone is ~4 lines/second. bool file = true; // written unless turned off std::string file_level; // empty => "debug" int file_max_mb = 20; // rotate past this int file_keep = 10; // fgc_*.log retained, oldest deleted std::string file_dir; // empty => paths::defaultLogDir() }; // [Startup]: what the tower does as it comes up. Absent keys are the idle // start: no homing, manual. The firmware keeps no position across a power // cycle, so a tower in service needs home = true - and then it homes UNATTENDED // at every start. mode = auto is refused at startup (and the tower stays in // manual) if the gimbal is not homed by then. struct StartupConfig { bool home = false; // run the endstop-finding home sequence at startup Mode mode = Mode::Manual; // manual or auto; pan needs a spec, so it is by command only }; 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). The CSV is the only source - it is loaded verbatim // and there is no generated fallback, so what a tower photographs is always a // file somebody wrote and can be read back (see ScanGrid). struct ScanConfig { std::string grid_file; // path to an editable yaw_deg,pitch_deg CSV; required for auto-sweep }; // [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 ThermalParams thermal; // [Thermal] the CPU guard, normally from hardware.ini FeaturesConfig features; LoggingConfig logging; UiConfig ui; // [UI] terminal dashboard toggle StartupConfig startup; // [Startup] home + mode at startup 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