Anchored yaw to 0-360 degrees range

This commit is contained in:
pgdalmeida 2026-06-29 22:33:00 +02:00
parent 5643e909d4
commit c972aba4d3
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
13 changed files with 209 additions and 38 deletions

View File

@ -2,4 +2,4 @@
REMOTE_HOST=ggs@10.11.12.111 REMOTE_HOST=ggs@10.11.12.111
REMOTE_DIR=/home/ggs/projects/fwt_2a/software REMOTE_DIR=/home/ggs/projects/fwt_2a/software
CMAKE_ARGS="-DWITH_MQTT=ON -DWITH_VIMBA=ON" CMAKE_ARGS="-DWITH_MQTT=ON -DWITH_VIMBA=ON"
RUN_ARGS="--start --trace serial" RUN_ARGS="--start"

View File

@ -51,16 +51,32 @@ baud = 115200
; used by MQTT (target_HDG) and CamEvent. CALIBRATE on the rig after homing: ; used by MQTT (target_HDG) and CamEvent. CALIBRATE on the rig after homing:
; counts = zero_count + deg * counts_per_deg ; counts = zero_count + deg * counts_per_deg
; counts_per_deg may be negative to flip direction. min/max_deg clamp commands. ; counts_per_deg may be negative to flip direction. min/max_deg clamp commands.
;
; Mechanical travel of this gimbal (between hard endstops, found at homing):
; Yaw ~320 deg (0 .. 320; NOT a full revolution, cannot wrap)
; Pitch 40 deg (-10 .. +30; -10 below horizon to +30 above)
; Keep all scan waypoints inside these ranges or the move clamps short.
;
; Yaw starting point: firmware steps_per_rev=177000 over ~180 deg => ~983.33. ; Yaw starting point: firmware steps_per_rev=177000 over ~180 deg => ~983.33.
yaw_counts_per_deg = 983.33 yaw_counts_per_deg = 983.33
yaw_zero_count = 500000 yaw_zero_count = 500000
yaw_min_deg = -90 yaw_min_deg = -90
yaw_max_deg = 90 yaw_max_deg = 90
; Pitch (physical endstops, ~0..60 deg). CALIBRATE counts_per_deg/zero_count. ; Re-anchor the yaw zero from homing instead of the fixed yaw_zero_count above:
; off (default) - use yaw_zero_count as written
; low - the lower-count endstop becomes 0 deg, degrees rise toward
; the higher-count endstop (which lands near +360)
; high - the higher-count endstop becomes 0 deg, degrees rise toward
; the lower-count endstop (which lands near +360)
; Keeps yaw_counts_per_deg as the scale; only the zero + direction come from the
; homed endstops, so the yaw origin is fixed by homing (no zero recalibration).
; yaw_min_deg/yaw_max_deg are auto-set to 0..(homed range). Pitch is unaffected.
yaw_home_zero = off
; Pitch (physical endstops, -10..+30 deg = 40 deg travel). CALIBRATE counts_per_deg/zero_count.
pitch_counts_per_deg = 8333.33 pitch_counts_per_deg = 8333.33
pitch_zero_count = 0 pitch_zero_count = 0
pitch_min_deg = 0 pitch_min_deg = -10
pitch_max_deg = 60 pitch_max_deg = 30
[Scan] [Scan]
; Capture scan grid (the (yaw,pitch) waypoints auto-sweep steps through). ; Capture scan grid (the (yaw,pitch) waypoints auto-sweep steps through).

View File

@ -1,24 +1,24 @@
# Scan grid — capture waypoints, one "yaw_deg,pitch_deg" per line. # Scan grid — capture waypoints," one ""yaw_deg","pitch_deg"" per line."
# Edit these to define the exact scan coordinates. Blank lines and lines # Edit these to define the exact scan coordinates. Blank lines and lines,,
# starting with '#' are ignored. The auto-sweep (ControlCode 0) walks the list # starting with '#' are ignored. The auto-sweep (ControlCode 0) walks the list,,
# forward then backward (ping-pong), triggering the cameras at each settled # forward then backward (ping-pong), triggering the cameras at each settled,
# point. Degrees are converted to encoder counts via the [Motor] calibration. # point. Degrees are converted to encoder counts via the [Motor] calibration.,,
# #,,
# Reference this file from config.ini: [Scan] grid_file = config/scan.csv # Reference this file from config.ini: [Scan] grid_file = config/scan.csv,,
# #,,
# Example: three elevation rows across a 180° yaw arc. # Example: three elevation rows across a 180° yaw arc.,,
-90,10 0,10,
-45,10 45,10,
0,10 90,10,
45,10 135,10,
90,10 180,10,
-90,30 0,5,
-45,30 45,5,
0,30 90,5,
45,30 135,5,
90,30 180,5,
-90,50 0,15,
-45,50 45,15,
0,50 90,15,
45,50 135,15,
90,50 180,15,

Can't render this file because it contains an unexpected character in line 1 and column 40.

View File

@ -77,6 +77,11 @@ struct ScanConfig {
std::string pitch_levels = "0"; // generated: comma list of pitch elevations (deg) std::string pitch_levels = "0"; // generated: comma list of pitch elevations (deg)
}; };
// Which homed endstop becomes yaw 0 deg, with degrees rising toward the other
// limit (which then lands near +360, a bit less for the soft-limit/mechanical
// gap). Off = use the configured/calibrated yaw zero_count as-is.
enum class YawHomeZero { Off, Low, High };
struct AppConfig { struct AppConfig {
GeneralConfig general; GeneralConfig general;
NetworkConfig network; NetworkConfig network;
@ -87,6 +92,7 @@ struct AppConfig {
LoggingConfig logging; LoggingConfig logging;
UiConfig ui; // [UI] terminal dashboard toggle UiConfig ui; // [UI] terminal dashboard toggle
Geometry geometry; // [Motor] degrees<->counts maps (yaw + pitch) Geometry geometry; // [Motor] degrees<->counts maps (yaw + pitch)
YawHomeZero yaw_home_zero = YawHomeZero::Off; // [Motor] re-anchor yaw zero on homing
ScanConfig scan; // [Scan] grid source ScanConfig scan; // [Scan] grid source
ImuConfig imu; // [IMU] Xsens MTi serial device ImuConfig imu; // [IMU] Xsens MTi serial device

View File

@ -18,6 +18,7 @@ struct DumpAxis {
bool has_encoder = false; bool has_encoder = false;
long lim_neg = 0; long lim_neg = 0;
long lim_pos = 0; long lim_pos = 0;
long soft_margin = 0; // counts kept clear of each hard limit
long hold_target = 0; long hold_target = 0;
long speed = 0; long speed = 0;
int hsub = 0; int hsub = 0;

View File

@ -58,7 +58,7 @@ public:
d << "DUMP " << L << " state=" << st d << "DUMP " << L << " state=" << st
<< " hsub=0 enabled=" << (homed_ ? 1 : 0) << " hsub=0 enabled=" << (homed_ ? 1 : 0)
<< " lim_neg=0 lim_pos=1000000 hold_target=" << a.xenc << " lim_neg=0 lim_pos=1000000 hold_target=" << a.xenc
<< " speed=200000 eeprom_restored=1 has_encoder=1\n"; << " speed=200000 eeprom_restored=1 soft_margin=5000 has_encoder=1\n";
d << "DUMP " << L << " TMC GCONF=0x00000004 GSTAT=0x00000000" d << "DUMP " << L << " TMC GCONF=0x00000004 GSTAT=0x00000000"
<< " IOIN=0x30000008 TSTEP=0x000fffff RAMPMODE=0x00000000" << " IOIN=0x30000008 TSTEP=0x000fffff RAMPMODE=0x00000000"
<< " XACTUAL=0x" << std::hex << (a.xactual & 0xffffffff) << " XACTUAL=0x" << std::hex << (a.xactual & 0xffffffff)

View File

@ -44,6 +44,9 @@ private:
std::thread ui_thread_; std::thread ui_thread_;
std::thread refresh_thread_; std::thread refresh_thread_;
std::atomic<bool> running_{false}; std::atomic<bool> running_{false};
// When set, the 100ms auto-redraw is suspended so the screen holds still and
// the operator can select/copy text (terminals drop a selection on repaint).
std::atomic<bool> paused_{false};
// Log ring buffer (newest last), filled by the Logger sink. // Log ring buffer (newest last), filled by the Logger sink.
std::mutex log_mutex_; std::mutex log_mutex_;

View File

@ -94,6 +94,7 @@ struct CaptureView {
std::vector<ScanWaypointView> scan_grid; std::vector<ScanWaypointView> scan_grid;
bool scan_from_file = false; // grid_file CSV vs generated bool scan_from_file = false; // grid_file CSV vs generated
bool scan_pitch = false; // 2-axis rig (show pitch column) 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()). // Last published capture (from ImagePipeline::lastEvent()).
bool has_last = false; bool has_last = false;
std::string last_label; std::string last_label;

View File

@ -27,6 +27,7 @@
#include <atomic> #include <atomic>
#include <cctype> #include <cctype>
#include <chrono> #include <chrono>
#include <cmath>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <memory> #include <memory>
@ -155,6 +156,7 @@ struct Application::Impl {
std::unique_ptr<CalibrationRoutine> calib; std::unique_ptr<CalibrationRoutine> calib;
unsigned last_diag_seq = 0; unsigned last_diag_seq = 0;
ScanGrid grid; // outlives scheduler (holds a reference to it) ScanGrid grid; // outlives scheduler (holds a reference to it)
std::string scan_load_error_; // non-empty if grid_file failed to load
// Persisted last-results for the TUI activity strip (survive in the log churn). // Persisted last-results for the TUI activity strip (survive in the log churn).
bool diag_running_ = false; bool diag_running_ = false;
@ -355,6 +357,7 @@ struct Application::Impl {
// scheduler that mutates it, so no locking is needed). // scheduler that mutates it, so no locking is needed).
s.capture.scan_from_file = !cfg.scan.grid_file.empty(); s.capture.scan_from_file = !cfg.scan.grid_file.empty();
s.capture.scan_pitch = t.pitch_present; s.capture.scan_pitch = t.pitch_present;
s.capture.scan_error = scan_load_error_;
s.capture.scan_grid.clear(); s.capture.scan_grid.clear();
const auto& pts = grid.points(); const auto& pts = grid.points();
s.capture.scan_grid.reserve(pts.size()); s.capture.scan_grid.reserve(pts.size());
@ -415,9 +418,19 @@ struct Application::Impl {
} else if (scheduler && scheduler->captureActive()) { } else if (scheduler && scheduler->captureActive()) {
a.active = true; a.active = true;
a.title = "CAPTURE"; a.title = "CAPTURE";
a.status = s.capture.has_last const char* phase = scheduler->moving() ? "moving" : "dwell";
? ("scanning · last " + s.capture.last_label) if (scheduler->controlCode() == 1) {
: "scanning…"; a.status = "directed → heading " + scheduler->targetHeading() + "° · " +
(scheduler->moving() ? "moving" : "holding");
} else if (!grid.empty()) {
const ScanPoint& p = grid.current();
std::string coord = "yaw " + formatDegrees(p.yaw_deg);
if (s.capture.scan_pitch) coord += " / pitch " + formatDegrees(p.pitch_deg);
a.status = "waypoint " + std::to_string(grid.index() + 1) + "/" +
std::to_string(grid.size()) + " · " + coord + " · " + phase;
} else {
a.status = "no scan grid — auto-sweep disabled";
}
} }
// Persisted result: the more recent of the last calibration / diagnostics. // Persisted result: the more recent of the last calibration / diagnostics.
@ -472,6 +485,7 @@ struct Application::Impl {
} }
// Wait for completion (both axes READY again) or the homing timeout. // Wait for completion (both axes READY again) or the homing timeout.
bool homed = false;
const auto deadline = std::chrono::steady_clock::now() + 65s; const auto deadline = std::chrono::steady_clock::now() + 65s;
while (running && std::chrono::steady_clock::now() < deadline) { while (running && std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(250ms); std::this_thread::sleep_for(250ms);
@ -484,15 +498,81 @@ struct Application::Impl {
} }
if (allReady(t)) { if (allReady(t)) {
LOG_INFO << "Gimbal homed (both axes READY)"; LOG_INFO << "Gimbal homed (both axes READY)";
homed = true;
break; break;
} }
} }
// Re-anchor the yaw zero to an endstop now that the homed limits are known.
if (homed) applyYawHomeZero();
// Production move speeds for subsequent MOVE commands. // Production move speeds for subsequent MOVE commands.
motor->sendCommand("SPEED Y 50000"); motor->sendCommand("SPEED Y 50000");
motor->sendCommand("SPEED P 150000"); motor->sendCommand("SPEED P 150000");
LOG_INFO << "Gimbal init finished"; LOG_INFO << "Gimbal init finished";
} }
// With [Motor] yaw_home_zero = low|high, redefine the yaw degree origin from
// the freshly homed endstops: the chosen endstop becomes 0 deg and degrees
// rise toward the other limit (forced positive, so it lands near +360). The
// counts_per_deg magnitude is kept from config/calibration; only the zero and
// sign change, so the yaw zero is fixed by homing and never needs a separate
// calibration. Pitch is left untouched.
void applyYawHomeZero() {
using namespace std::chrono_literals;
if (cfg.yaw_home_zero == YawHomeZero::Off) return;
// The homed limits live in the firmware DUMP; pull a fresh one and wait
// briefly for it to arrive with yaw endstops populated.
motor->sendCommand("DUMP");
long lo = 0, hi = 0, margin = 0;
bool got = false;
const auto deadline = std::chrono::steady_clock::now() + 3s;
while (running && std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(100ms);
publishSnapshot();
DumpData dd = parseDump(motor->lastDump());
if (dd.valid)
for (const auto& a : dd.axes)
if (a.axis == 'Y' && a.lim_pos != a.lim_neg) {
lo = a.lim_neg; hi = a.lim_pos; margin = a.soft_margin; got = true;
}
if (got) break;
}
if (!got) {
LOG_WARN << "yaw home-zero: no homed yaw limits in dump; keeping configured zero";
return;
}
if (margin <= 0)
LOG_WARN << "yaw home-zero: firmware reported no soft_margin (update firmware); "
"anchoring at the hard homing limit instead of the soft limit";
const double mag = std::fabs(cfg.geometry.yaw.counts_per_deg);
if (mag < 1e-9) {
LOG_WARN << "yaw home-zero: yaw_counts_per_deg ~0; calibrate first; keeping zero";
return;
}
// Anchor at the soft limits (the actual reachable travel), not the hard
// homing endstops, so the rightmost *reachable* position reads exactly 0.
const long lo_soft = lo + margin;
const long hi_soft = hi - margin;
const bool zero_high = (cfg.yaw_home_zero == YawHomeZero::High);
const long zero_cnt = zero_high ? hi_soft : lo_soft;
const long other_cnt = zero_high ? lo_soft : hi_soft;
// Sign so the other endstop sits at positive degrees (rises toward ~+360).
const double cpd = (other_cnt > zero_cnt) ? mag : -mag;
const double range_deg = std::fabs(static_cast<double>(other_cnt - zero_cnt)) / mag;
AxisMap& y = cfg.geometry.yaw;
y.zero_count = zero_cnt;
y.counts_per_deg = cpd;
y.min_deg = 0.0;
y.max_deg = range_deg;
if (scheduler) scheduler->setGeometry(cfg.geometry); // re-target the scan with it
LOG_INFO << "yaw zero anchored at " << (zero_high ? "high" : "low")
<< "-count endstop: 0.0.." << range_deg << " deg (zero_count=" << zero_cnt
<< ", counts_per_deg=" << cpd << ")";
}
void startCapture() { void startCapture() {
// The scheduler arms regardless, but a scan can't progress until the axes // The scheduler arms regardless, but a scan can't progress until the axes
// are homed: before READY the firmware leaves coils disabled and clamps // are homed: before READY the firmware leaves coils disabled and clamps
@ -651,6 +731,11 @@ struct Application::Impl {
} }
LOG_INFO << "gimbal raw -> " << raw; LOG_INFO << "gimbal raw -> " << raw;
motor->sendCommand(raw); motor->sendCommand(raw);
} else if (sub == "home" && tok.size() <= 2) {
// Full re-home: enable + HOME + wait for READY + re-anchor the yaw zero
// (same path as startup --init), so the homing key behaves like a boot
// home rather than a fire-and-forget HOME that skips the re-anchor.
runInitSequence();
} else if (sub == "home" || sub == "stop" || sub == "reset" || sub == "enable" || } else if (sub == "home" || sub == "stop" || sub == "reset" || sub == "enable" ||
sub == "disable" || sub == "speed" || sub == "setpos" || sub == "status") { sub == "disable" || sub == "speed" || sub == "setpos" || sub == "status") {
// Passthrough to the (case-insensitive) firmware verb + args. // Passthrough to the (case-insensitive) firmware verb + args.
@ -874,6 +959,7 @@ struct Application::Impl {
try { try {
grid = ScanGrid::fromConfig(cfg.scan); grid = ScanGrid::fromConfig(cfg.scan);
} catch (const std::exception& e) { } catch (const std::exception& e) {
scan_load_error_ = e.what();
LOG_WARN << "scan grid load failed (" << e.what() << "); auto-sweep disabled"; LOG_WARN << "scan grid load failed (" << e.what() << "); auto-sweep disabled";
} }
LOG_INFO << "scan grid: " << grid.size() << " waypoints"; LOG_INFO << "scan grid: " << grid.size() << " waypoints";
@ -904,7 +990,15 @@ struct Application::Impl {
cmd_queue.push(line); cmd_queue.push(line);
}); });
if (opts.init) runInitSequence(); if (opts.init) {
runInitSequence(); // homes, then re-anchors the yaw zero
} else if (cfg.yaw_home_zero != YawHomeZero::Off) {
// No homing this launch: the firmware restores its homed limits from
// EEPROM to READY on boot, so re-derive the same yaw zero from them.
// This makes the homing-anchored zero persist across software restarts
// without re-homing. Self-guards (no-op + note) if not yet homed.
applyYawHomeZero();
}
if (opts.start) startCapture(); if (opts.start) startCapture();
LOG_INFO << "Entering control loop (type 'exit' to quit)"; LOG_INFO << "Entering control loop (type 'exit' to quit)";

View File

@ -95,8 +95,10 @@ void CaptureScheduler::tick() {
const ScanPoint& p = grid_.current(); const ScanPoint& p = grid_.current();
yaw_target_ = geometry_.yaw.toCounts(p.yaw_deg); yaw_target_ = geometry_.yaw.toCounts(p.yaw_deg);
pitch_target_ = geometry_.pitch.toCounts(p.pitch_deg); pitch_target_ = geometry_.pitch.toCounts(p.pitch_deg);
LOG_TRACE_CAT(LogCat::Control) // One INFO line per move so the operator can follow the scan in the
<< "sweep -> grid yaw=" << p.yaw_deg << " pitch=" << p.pitch_deg; // log without enabling the control wire-trace.
LOG_INFO << "scan move " << (grid_.index() + 1) << "/" << grid_.size()
<< ": yaw " << p.yaw_deg << " pitch " << p.pitch_deg << " (deg)";
} else { // ControlCode 1: directed yaw, pitch held at current position. } else { // ControlCode 1: directed yaw, pitch held at current position.
try { try {
yaw_target_ = geometry_.yaw.toCounts(std::stod(target_heading_)); yaw_target_ = geometry_.yaw.toCounts(std::stod(target_heading_));
@ -104,7 +106,7 @@ void CaptureScheduler::tick() {
return; // bad heading; wait for a valid one return; // bad heading; wait for a valid one
} }
pitch_target_ = t.pitch.xenc; pitch_target_ = t.pitch.xenc;
LOG_TRACE_CAT(LogCat::Control) << "directed -> heading " << target_heading_; LOG_INFO << "directed move: heading " << target_heading_ << " (deg)";
} }
sendMove(); sendMove();
moving_ = true; moving_ = true;
@ -117,6 +119,10 @@ void CaptureScheduler::tick() {
LOG_TRACE_CAT(LogCat::Control) LOG_TRACE_CAT(LogCat::Control)
<< "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc << "; triggering"; << "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc << "; triggering";
if (camera_.trigger()) { if (camera_.trigger()) {
if (control_code_ == 0)
LOG_INFO << "captured waypoint " << (grid_.index() + 1) << "/" << grid_.size();
else
LOG_INFO << "captured (directed)";
moving_ = false; moving_ = false;
if (control_code_ == 0) grid_.advance(); if (control_code_ == 0) grid_.advance();
resetTimer(); resetTimer();

View File

@ -1,8 +1,10 @@
#include "fgc/Config.h" #include "fgc/Config.h"
#include "fgc/Paths.h" #include "fgc/Paths.h"
#include <cctype>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <filesystem>
#include <fstream> #include <fstream>
#include <sstream> #include <sstream>
#include <stdexcept> #include <stdexcept>
@ -131,6 +133,13 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.geometry.pitch.zero_count = getLong(kv, "Motor.pitch_zero_count", cfg.geometry.pitch.zero_count); cfg.geometry.pitch.zero_count = getLong(kv, "Motor.pitch_zero_count", cfg.geometry.pitch.zero_count);
cfg.geometry.pitch.min_deg = getDouble(kv, "Motor.pitch_min_deg", cfg.geometry.pitch.min_deg); cfg.geometry.pitch.min_deg = getDouble(kv, "Motor.pitch_min_deg", cfg.geometry.pitch.min_deg);
cfg.geometry.pitch.max_deg = getDouble(kv, "Motor.pitch_max_deg", cfg.geometry.pitch.max_deg); cfg.geometry.pitch.max_deg = getDouble(kv, "Motor.pitch_max_deg", cfg.geometry.pitch.max_deg);
{
std::string hz = get(kv, "Motor.yaw_home_zero", "off");
for (auto& ch : hz) ch = static_cast<char>(std::tolower((unsigned char)ch));
cfg.yaw_home_zero = hz == "high" ? YawHomeZero::High
: hz == "low" ? YawHomeZero::Low
: YawHomeZero::Off;
}
// [Scan]: scan-grid source (explicit CSV file, else generated). // [Scan]: scan-grid source (explicit CSV file, else generated).
cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file); cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file);
@ -156,7 +165,16 @@ AppConfig ConfigLoader::loadFromFile(const std::string& path) {
if (rc > 0) if (rc > 0)
throw std::runtime_error("Parse error in config file " + path + " at line " + throw std::runtime_error("Parse error in config file " + path + " at line " +
std::to_string(rc)); std::to_string(rc));
return fromMap(kv); AppConfig cfg = fromMap(kv);
// Anchor a relative scan grid_file to the config file's directory so it resolves
// no matter which working directory the process is launched from (the config
// itself is found via a search path that may be unrelated to the cwd).
if (!cfg.scan.grid_file.empty()) {
std::filesystem::path gf(paths::expandUser(cfg.scan.grid_file));
if (gf.is_relative()) gf = std::filesystem::path(path).parent_path() / gf;
cfg.scan.grid_file = gf.lexically_normal().string();
}
return cfg;
} }
namespace { namespace {

View File

@ -159,6 +159,7 @@ DumpData parseDump(const std::string& block) {
ax.has_encoder = asInt(get(m, "has_encoder", "0")) != 0; ax.has_encoder = asInt(get(m, "has_encoder", "0")) != 0;
ax.lim_neg = asInt(get(m, "lim_neg", "0")); ax.lim_neg = asInt(get(m, "lim_neg", "0"));
ax.lim_pos = asInt(get(m, "lim_pos", "0")); ax.lim_pos = asInt(get(m, "lim_pos", "0"));
ax.soft_margin = asInt(get(m, "soft_margin", "0"));
ax.hold_target = asInt(get(m, "hold_target", "0")); ax.hold_target = asInt(get(m, "hold_target", "0"));
ax.speed = asInt(get(m, "speed", "0")); ax.speed = asInt(get(m, "speed", "0"));
} }

View File

@ -116,6 +116,10 @@ Element cameraPanel(const CaptureView& c) {
hbox({text("capture ") | dim, active, filler(), hbox({text("capture ") | dim, active, filler(),
text(std::to_string(c.image_rate) + " img/s") | dim}), text(std::to_string(c.image_rate) + " img/s") | dim}),
}; };
if (!c.scan_error.empty())
rows.push_back(hbox({text("scan ") | dim,
text(" GRID LOAD FAILED ") | color(Color::Red) | bold,
text(" c for details") | dim}));
if (c.has_last) { if (c.has_last) {
long long now = std::chrono::duration_cast<std::chrono::milliseconds>( long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count(); std::chrono::system_clock::now().time_since_epoch()).count();
@ -145,6 +149,16 @@ Element cameraDetailPanel(const CaptureView& c) {
separator(), separator(),
}; };
if (!c.scan_error.empty()) {
rows.push_back(hbox({text(" GRID LOAD FAILED ") | color(Color::Red) | bold,
text(" auto-sweep disabled") | dim}));
rows.push_back(hbox({text("reason ") | dim, text(c.scan_error) | color(Color::Red)}));
rows.push_back(text("Check [Scan] grid_file in the loaded config (see the startup "
"'Loaded config:' log line).") | dim);
return window(text(" SCAN GRID (c/Esc:close) ") | bold | color(Color::Cyan),
vbox(std::move(rows)));
}
if (c.scan_grid.empty()) { if (c.scan_grid.empty()) {
rows.push_back(text("(no scan grid defined — auto-sweep disabled)") | dim); rows.push_back(text("(no scan grid defined — auto-sweep disabled)") | dim);
return window(text(" SCAN GRID (c/Esc:close) ") | bold | color(Color::Cyan), return window(text(" SCAN GRID (c/Esc:close) ") | bold | color(Color::Cyan),
@ -573,6 +587,7 @@ void TuiUi::refreshLoop() {
while (running_) { while (running_) {
std::this_thread::sleep_for(100ms); std::this_thread::sleep_for(100ms);
if (!running_) break; if (!running_) break;
if (paused_) continue; // frozen for text selection; don't repaint
if (auto* s = screen_.load()) s->PostEvent(Event::Custom); // force a redraw if (auto* s = screen_.load()) s->PostEvent(Event::Custom); // force a redraw
} }
} }
@ -602,6 +617,8 @@ void TuiUi::uiLoop() {
text(" " + s.header.tower + " ") | bold, text(" " + s.header.tower + " ") | bold,
text("build " + s.header.build) | dim, text("build " + s.header.build) | dim,
filler(), filler(),
(paused_ ? text(" PAUSED ") | color(Color::Black) | bgcolor(Color::Yellow) | bold
: text("")),
text(" " + mode + " ") | (s.header.live ? color(Color::Green) : color(Color::Yellow)) | bold, text(" " + mode + " ") | (s.header.live ? color(Color::Green) : color(Color::Yellow)) | bold,
text(" up " + std::to_string(s.header.uptime_ms / 1000) + "s ") | dim, text(" up " + std::to_string(s.header.uptime_ms / 1000) + "s ") | dim,
}); });
@ -616,7 +633,7 @@ void TuiUi::uiLoop() {
bottom = hbox({ bottom = hbox({
keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"), keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"),
keyHint("g", "Gimbal"), keyHint("i", "IMU"), keyHint("c", "Scan"), keyHint("g", "Gimbal"), keyHint("i", "IMU"), keyHint("c", "Scan"),
keyHint("r", "Refresh"), keyHint("p", "Pause"), keyHint("r", "Refresh"),
keyHint("\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93", "Nudge"), keyHint("\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93", "Nudge"),
keyHint(":", "Cmd"), keyHint("?", "Help"), filler(), keyHint("q", "Quit"), keyHint(":", "Cmd"), keyHint("?", "Help"), filler(), keyHint("q", "Quit"),
}); });
@ -712,6 +729,10 @@ void TuiUi::uiLoop() {
overlay = (overlay == Overlay::Cameras) ? Overlay::None : Overlay::Cameras; overlay = (overlay == Overlay::Cameras) ? Overlay::None : Overlay::Cameras;
return true; return true;
} }
if (c == "p") { // freeze/unfreeze the live refresh so text can be selected
paused_ = !paused_;
return true; // this keypress redraws once so the indicator updates
}
if (overlay == Overlay::Help) { // vim-style section nav while help is open if (overlay == Overlay::Help) { // vim-style section nav while help is open
if (c == "j") { help_sel = (help_sel + 1) % n; return true; } if (c == "j") { help_sel = (help_sel + 1) % n; return true; }
if (c == "k") { help_sel = (help_sel - 1 + n) % n; return true; } if (c == "k") { help_sel = (help_sel - 1 + n) % n; return true; }
@ -728,6 +749,10 @@ void TuiUi::uiLoop() {
// ScreenInteractive can't be moved, so it lives on this thread's stack; the // ScreenInteractive can't be moved, so it lives on this thread's stack; the
// atomic pointer lets stop()/refreshLoop() reach it. // atomic pointer lets stop()/refreshLoop() reach it.
ScreenInteractive screen = ScreenInteractive::Fullscreen(); ScreenInteractive screen = ScreenInteractive::Fullscreen();
// The dashboard is keyboard-only, so don't grab the mouse: with mouse
// tracking off the terminal keeps its native click-drag selection, so the
// operator can select/copy text (waypoints, log lines, register dumps).
screen.TrackMouse(false);
screen_.store(&screen); screen_.store(&screen);
if (running_) screen.Loop(root); if (running_) screen.Loop(root);
screen_.store(nullptr); screen_.store(nullptr);