#pragma once #include "fgc/IControlChannel.h" #include "fgc/ImuMount.h" #include "fgc/IMotorController.h" // AxisState #include "fgc/Logger.h" // LogLevel #include #include namespace fgc { // Plain-data view model handed from the control loop to a user interface. It is // a pure snapshot: no references to the live motor/camera/channel objects, so // the UI thread can copy and render it without touching (or racing) application // logic. New subsystems become new structs here + a panel in the TUI. // Colour intent, mapped to concrete terminal colours by the renderer. enum class UiColor { Default, Dim, Green, Yellow, Red, Cyan }; // One gimbal axis. struct AxisView { std::string label; // "YAW" / "PITCH" AxisState state = AxisState::Unknown; double deg = 0.0; // heading/elevation, from xenc via Geometry long xactual = 0; long xenc = 0; long enc_err = 0; // live tracking error = xactual - xenc (counts) double target_deg = 0.0; // current scheduler target double counts_per_deg = 0.0; // live scale, for step-size readouts int sg = 0; // SG_RESULT (live, from ST line) int cs = 0; // CS_ACTUAL int pwm = 0; // PWM_SCALE_SUM unsigned drv_status = 0; // DRV_STATUS register bool moving = false; bool standstill = false; bool stall = false; // StallGuard ('s') - inert on this rig bool deviation = false; // encoder-deviation latch ('d') - the real stall signal bool overtemp = false; bool endstop_l = false; bool endstop_r = false; // Homing travel limits (from the last firmware dump): the endstop positions // found during homing, in encoder counts and converted to degrees. bool has_limits = false; long lim_neg = 0; long lim_pos = 0; double lim_neg_deg = 0.0; double lim_pos_deg = 0.0; // Encoder-tracking health (from the last firmware dump): disturbances the // firmware hold corrector counted/erased. has_track gates rendering. bool has_track = false; long hold_corrections = 0; long hold_peak_dev = 0; long hold_cumulative_dev = 0; }; struct GimbalView { bool present = false; // motor link usable AxisView yaw; AxisView pitch; bool pitch_present = false; }; // One labelled sensor reading. `present=false` => render the dim "pending" form. struct SensorField { std::string label; // "Temp", "Humid", "Roll"... std::string value; // formatted value, or placeholder when absent std::string unit; // "°C", "%RH", "°"... bool present = false; }; // A labelled group of readings from one physical sensor, rendered as its own // subsection in the Sensors panel. struct SensorGroup { std::string title; // "IMU (orientation)" / "SHT41 (ambient)" std::string status = "pending"; // "live" / "pending" / "no fix" bool present = false; // true once the driver feeds real data std::vector fields; }; // The Sensors panel, split by source so it is clear which parameters come from // which device. Each group is filled with pending placeholders until its driver // lands. NOTE: the only temperature here is the env sensor's *ambient* one — the // MTi's *internal* device temperature is diagnostic detail and lives solely in // the expanded view (ImuView::temp_c), so the compact panel never shows two // temperatures the operator has to tell apart. The env group's title/backing // sensor is swappable (currently SHT41 over I2C; see IEnvSensor) without // touching this struct. struct SensorsView { SensorGroup imu; // Xsens MTi: Roll/Pitch/Yaw SensorGroup env; // ambient temperature + humidity (SHT41) // The MTi is not streaming what the host configured (the detail is in // ImuConfigView::problems). Flagged on the compact panel too, because the // readings above are wrong in a way that reading them cannot reveal. bool imu_config_alert = false; }; // One auto-sweep scan-grid waypoint, for the expanded camera view. struct ScanWaypointView { double yaw_deg = 0.0; double pitch_deg = 0.0; bool current = false; // the scheduler's live cursor position }; // Static imaging + encoding config (from CameraConfig), for the expanded view. // One configured camera, as the expanded view shows it. Everything here is // explicit per camera - identity is never implied by position. struct CameraView { std::string name; // the [Camera.] block std::string label; // output subdir + CamEvent "cam" std::string id; // USB DEV_ id or GigE IP std::string driver; bool enabled = true; std::string profile; // active profile std::vector profiles_available; std::string profile_dir; std::string profile_xml; // the file actually applied to the camera int settle_wait_ms = 0; int rotation_deg = 0; double jxl_distance = 0.0; int jxl_effort = 0; }; struct CameraConfigView { bool mock = false; std::vector cameras; std::string output_dir; }; // Live per-camera identity/telemetry/stats (from ICameraSource::deviceInfo()). struct CameraDeviceView { std::string id, model, serial; bool streaming = false; unsigned width = 0, height = 0; long long payload_bytes = 0; double exposure_us = 0, gain_db = 0, temperature_c = 0, actual_fps = 0; long long frames_delivered = 0, frames_dropped = 0, restarts = 0; }; struct CaptureView { bool present = false; bool active = false; // No scan running, so hand aiming (arrows) and `snapshot` are accepted. bool manual = true; bool snapshot_armed = false; // a snapshot is waiting to fire double image_rate = 0.0; // img/s int camera_count = 0; std::vector labels; // Full camera-system detail for the expanded ('c') view. CameraConfigView config; std::vector devices; long long images_saved = 0; // Defined scan grid (the auto-mode waypoints) with the live cursor // flagged, surfaced in the expanded camera view. std::vector scan_grid; bool scan_from_file = false; // grid_file CSV vs generated bool scan_pitch = false; // 2-axis rig (show pitch column) std::string scan_error; // grid_file load failure (auto-sweep off) // Last published capture (from ImagePipeline::lastEvent()). bool has_last = false; std::string last_label; double last_heading_deg = 0.0; double last_pitch_deg = 0.0; long long last_ts_ms = 0; }; struct ConnView { bool mqtt_enabled = false; bool mqtt_connected = false; std::string broker; std::string tower; std::string mode = "manual"; // auto / pan / manual int status_code = 2; // what StatusCode carries for it // Recent traffic + counters for the expanded 'm' view. std::vector recent; ChannelStats stats; }; struct HeaderView { std::string tower; std::string build; long long uptime_ms = 0; bool live = true; // LIVE vs MOCK std::string mode = "manual"; // auto / pan / manual (OperatingMode.h) }; struct LogLine { LogLevel level = LogLevel::Info; std::string text; // formatted line (no trailing newline) }; // Most recent firmware DUMP block (raw text), surfaced in the TUI help pane. struct DumpView { bool has = false; std::string text; }; // Device configuration read back from the MTi at startup, formatted for the // expanded Sensors view. `present` is false until the handshake reports it (or // for backends that cannot report it). // One XKF (Xsens Kalman Filter) profile the device supports, with whether it is // the active one. struct ImuProfileView { std::string name; // human label, no numeric IDs ("General") bool selected = false; // the profile currently in use }; struct ImuConfigView { bool present = false; std::string product_code; // "MTi-28A53G35" std::string device_id; // "0x00990ABC" std::string firmware; // "2.8.1 build 0" std::string output_mode; // "Temp · Calibrated · Orientation" std::string output_settings; // "Euler · Sample counter · Float" std::string channels; // "acc gyr mag" std::string sample_rate; // "100 Hz" // Available XKF profiles with the active one flagged. Empty if the device // did not report them. std::vector xkf_profiles; // The [IMU] x/y/z mounting rotation being applied to every reading, already // formatted (e.g. "x 180.0 y 0.0 z -90.0"). Empty when no mount is // configured. Unlike everything above it comes from config.ini rather than // from the device, so the view labels it as such - the MTi has its own // internal alignment setting and the two must not be confused. std::string mount; // How the device disagreed with what the host asked it for at startup, one // finding per line, already formatted. Empty when it agreed. // // Like `mount` this must survive the config cache, and for a sharper reason: // the handshake runs before the TUI installs its log sink, so the LOG_ERROR // it emits is wiped by the alt-screen. Without this field the operator would // have no way of learning that the IMU is streaming the wrong thing. std::vector problems; }; // Full Xsens MTi reading for the expanded Sensors view (units: acc m/s^2, // gyr rad/s, mag a.u., angles deg, temp °C). struct ImuView { bool present = false; float roll_deg = 0, pitch_deg = 0, yaw_deg = 0; float acc[3] = {0, 0, 0}; float gyr[3] = {0, 0, 0}; float mag[3] = {0, 0, 0}; float temp_c = 0; unsigned sample_counter = 0; ImuConfigView config; }; // Full ambient-sensor reading for the expanded Sensors view. This temperature is // *ambient*, as distinct from ImuView::temp_c (the MTi's device-internal temp); // the expanded view labels both so they are never confused. struct EnvView { bool enabled = false; // driver constructed (enable_env / --mock-env) bool present = false; // a fresh (non-stale) sample is available bool mock = false; // MockEnvSensor: the I2C bus config is meaningless std::string name; // IEnvSensor::name() — "SHT41" / "SHT41 (mock)" float temp_c = 0, humidity_pct = 0; long long timestamp_ms = 0; // epoch ms, for formatTimeAgo() std::string i2c_device; // from EnvConfig, for the bus row unsigned i2c_addr = 0; int period_ms = 0; }; // Status of the currently-running special operation (calibration, diagnostics, // homing, capture) plus the last completed result, for the activity strip below // the log. The result block persists until replaced so it never scrolls away. struct ActivityView { bool active = false; // a special op is running now std::string title; // "CALIBRATING" / "DIAGNOSTICS" / "HOMING" / "CAPTURE" std::string status; // live one-line progress bool has_result = false; std::string result_title; // e.g. "Calibration · 2m ago" std::vector result; // summary lines UiColor result_color = UiColor::Default; // green pass / red fail std::string prompt; // a yes/no question awaiting the operator (empty = none) bool cancelable = false; // a procedure is running and Esc cancels it // Heading setup runs in the strip: the TUI then takes the arrows for aiming, // Tab for the step size, Enter to mark and f to fit. bool heading_setup = false; std::vector detail; // extra lines under the live one (the marks) }; // Last calibration fit, per axis, for the gimbal expanded view. struct CalibAxisView { bool ok = false; double counts_per_deg = 0; double deg_at_center = 0; double r2 = 0; int n = 0; }; // The degrees<->counts map an axis is actually using right now: from config.ini // at startup, replaced in memory by a fit or a homing derivation. Distinct from // CalibAxisView, which describes the last FIT and only exists once one has run. struct AxisMapView { double counts_per_deg = 0; double deg_at_center = 0; }; struct CalibResultView { bool has = false; // has a fit run THIS SESSION? long long ts_ms = 0; bool pitch_present = false; CalibAxisView yaw, pitch; // Always populated. "No fit this session" is not the same as "uncalibrated": // the working calibration usually comes from config.ini and predates the run. AxisMapView live_yaw, live_pitch; }; // Worst-case encoder tracking error from the last DIAG run, per axis, for the // gimbal expanded view. err_* are the largest values across that axis's tests // (-1 when the axis has no encoder). struct DiagAxisView { char axis = '?'; bool has = false; bool pass = false; long err_peak = 0; // worst following error during the moves (counts) long err_rms = 0; // worst RMS following error (counts) long err_still = 0; // worst standstill error (counts) }; struct DiagResultView { bool has = false; long long ts_ms = 0; std::vector axes; // Y and (if present) P }; // Outcome of the last `test` (hardware self-test) run, for the activity strip. struct TestResultView { bool has = false; long long ts_ms = 0; bool pass = false; std::string profile; std::vector summary; // per-section PASS/FAIL lines }; struct UiSnapshot { HeaderView header; GimbalView gimbal; SensorsView sensors; CaptureView capture; ConnView conn; std::vector log; std::string log_file; // the file the log is being written to ("" = none) DumpView dump; ImuView imu; EnvView env; ActivityView activity; CalibResultView calib; DiagResultView diag; TestResultView test; }; // ---- Pure formatting helpers (unit-tested in tests/test_uisnapshot.cpp) ---- // Short state label for an axis state char (B/R/H/A/E -> these strings). const char* axisStateLabel(AxisState s); // Colour intent for an axis state (READY=green, HOMING=yellow, ERROR=red, // BOOT/RESET=dim, Unknown=default). UiColor axisStateColor(AxisState s); // "12.3°" (one decimal, sign preserved). std::string formatDegrees(double deg); // The [IMU] mount as one line: "x 180.0 y 0.0 z -90.0", or "" for no mount. // Empty rather than "none" so a caller can tell "not configured" from a value // and decide for itself how to say so. std::string formatImuMount(const ImuMount& m); // Human "Ns ago" / "Nm ago" for an absolute ms timestamp relative to now_ms. // Returns "—" when then_ms is 0 (never). std::string formatTimeAgo(long long now_ms, long long then_ms); // Whether the dashboard can be sent to the background, i.e. whether it is // running inside a tmux pane that a client can be detached from. Pass getenv //("TMUX"): tmux sets it to the server socket path, so a set, non-empty value is // both the test and what lets `tmux detach-client` find the right client. // Takes the value as an argument rather than reading the environment itself so // the rule is testable. bool backgroundAvailable(const char* tmux_env); // Last path segment of an MQTT topic ("GGS/FWT/Tower/CamEvent" -> "CamEvent"). // Every row in the connectivity view shares the same prefix, which only eats // width. Returns the input unchanged when there is no '/'. std::string topicLeaf(const std::string& topic); // "11:56:14.201" (local wall clock) for an absolute ms timestamp; "—" when 0. // The message history needs the actual time, not "3s ago": operators correlate // it against the broker's own log. std::string formatClockMs(long long ms); // MVP placeholder: the Sensors panel fields shown before the DHT11/MTi drivers // exist (all `present=false`). Centralised so the panel and tests agree. SensorsView pendingSensorsView(); } // namespace fgc