fwt_software/include/fgc/Config.h

470 lines
23 KiB
C++

#pragma once
#include "fgc/ImuMount.h"
#include "fgc/Geometry.h"
#include "fgc/OperatingMode.h"
#include <map>
#include <string>
#include <utility>
#include <vector>
namespace fgc {
// Typed application configuration. Replaces the ad-hoc std::map<string,string>
// 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<std::string> 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<std::string> cameras; // the [Camera.<name>] 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.<axis>] - 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.<axis>] 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] <axis>_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<double>(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.<name>.<profile>]. 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.<name>] 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;
// De-Bayering for raw profiles (BayerRG8/12p): "edge_aware" or "bilinear".
std::string demosaic = "edge_aware";
std::map<std::string, CameraProfileTiming> 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<CameraSpec> 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;
std::string demosaic = "edge_aware";
// Enabled cameras only.
std::vector<const CameraSpec*> 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
// 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.<name>]: 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<std::string, double> params; // numeric knobs: reps, *_window_ms, *_max_*
std::map<std::string, std::string> 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<long> getLongList(const std::string& key) const; // parse "200,500,1000"
};
struct TestConfig {
std::string default_profile = "standard";
std::string baseline_file; // empty => <logdir>/test_baseline.txt
std::map<std::string, TestProfile> 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
StartupConfig startup; // [Startup] home + mode at startup
Geometry geometry; // [Motor] degrees<->counts maps (yaw + pitch)
DrivetrainConfig drivetrain; // [Motor.<axis>] 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<std::string, std::string>& 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<std::pair<std::string, std::string>>& 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