443 lines
22 KiB
C++
443 lines
22 KiB
C++
#pragma once
|
|
|
|
#include "fgc/ImuMount.h"
|
|
#include "fgc/Geometry.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;
|
|
|
|
// 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
|
|
};
|
|
|
|
// 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;
|
|
|
|
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;
|
|
|
|
// 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()
|
|
};
|
|
|
|
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
|
|
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
|