Anchored yaw to 0-360 degrees range
This commit is contained in:
parent
5643e909d4
commit
c972aba4d3
|
|
@ -2,4 +2,4 @@
|
|||
REMOTE_HOST=ggs@10.11.12.111
|
||||
REMOTE_DIR=/home/ggs/projects/fwt_2a/software
|
||||
CMAKE_ARGS="-DWITH_MQTT=ON -DWITH_VIMBA=ON"
|
||||
RUN_ARGS="--start --trace serial"
|
||||
RUN_ARGS="--start"
|
||||
|
|
|
|||
|
|
@ -51,16 +51,32 @@ baud = 115200
|
|||
; used by MQTT (target_HDG) and CamEvent. CALIBRATE on the rig after homing:
|
||||
; counts = zero_count + deg * counts_per_deg
|
||||
; 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_counts_per_deg = 983.33
|
||||
yaw_zero_count = 500000
|
||||
yaw_min_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_zero_count = 0
|
||||
pitch_min_deg = 0
|
||||
pitch_max_deg = 60
|
||||
pitch_min_deg = -10
|
||||
pitch_max_deg = 30
|
||||
|
||||
[Scan]
|
||||
; Capture scan grid (the (yaw,pitch) waypoints auto-sweep steps through).
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
# Scan grid — capture waypoints, one "yaw_deg,pitch_deg" per line.
|
||||
# Edit these to define the exact scan coordinates. Blank lines and lines
|
||||
# starting with '#' are ignored. The auto-sweep (ControlCode 0) walks the list
|
||||
# forward then backward (ping-pong), triggering the cameras at each settled
|
||||
# point. Degrees are converted to encoder counts via the [Motor] calibration.
|
||||
#
|
||||
# Reference this file from config.ini: [Scan] grid_file = config/scan.csv
|
||||
#
|
||||
# Example: three elevation rows across a 180° yaw arc.
|
||||
-90,10
|
||||
-45,10
|
||||
0,10
|
||||
45,10
|
||||
90,10
|
||||
-90,30
|
||||
-45,30
|
||||
0,30
|
||||
45,30
|
||||
90,30
|
||||
-90,50
|
||||
-45,50
|
||||
0,50
|
||||
45,50
|
||||
90,50
|
||||
# Scan grid — capture waypoints," one ""yaw_deg","pitch_deg"" per line."
|
||||
# Edit these to define the exact scan coordinates. Blank lines and lines,,
|
||||
# starting with '#' are ignored. The auto-sweep (ControlCode 0) walks the list,,
|
||||
# forward then backward (ping-pong), triggering the cameras at each settled,
|
||||
# point. Degrees are converted to encoder counts via the [Motor] calibration.,,
|
||||
#,,
|
||||
# Reference this file from config.ini: [Scan] grid_file = config/scan.csv,,
|
||||
#,,
|
||||
# Example: three elevation rows across a 180° yaw arc.,,
|
||||
0,10,
|
||||
45,10,
|
||||
90,10,
|
||||
135,10,
|
||||
180,10,
|
||||
0,5,
|
||||
45,5,
|
||||
90,5,
|
||||
135,5,
|
||||
180,5,
|
||||
0,15,
|
||||
45,15,
|
||||
90,15,
|
||||
135,15,
|
||||
180,15,
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 1 and column 40.
|
|
|
@ -77,6 +77,11 @@ struct ScanConfig {
|
|||
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 {
|
||||
GeneralConfig general;
|
||||
NetworkConfig network;
|
||||
|
|
@ -87,6 +92,7 @@ struct AppConfig {
|
|||
LoggingConfig logging;
|
||||
UiConfig ui; // [UI] terminal dashboard toggle
|
||||
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
|
||||
ImuConfig imu; // [IMU] Xsens MTi serial device
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ struct DumpAxis {
|
|||
bool has_encoder = false;
|
||||
long lim_neg = 0;
|
||||
long lim_pos = 0;
|
||||
long soft_margin = 0; // counts kept clear of each hard limit
|
||||
long hold_target = 0;
|
||||
long speed = 0;
|
||||
int hsub = 0;
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ public:
|
|||
d << "DUMP " << L << " state=" << st
|
||||
<< " hsub=0 enabled=" << (homed_ ? 1 : 0)
|
||||
<< " 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"
|
||||
<< " IOIN=0x30000008 TSTEP=0x000fffff RAMPMODE=0x00000000"
|
||||
<< " XACTUAL=0x" << std::hex << (a.xactual & 0xffffffff)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ private:
|
|||
std::thread ui_thread_;
|
||||
std::thread refresh_thread_;
|
||||
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.
|
||||
std::mutex log_mutex_;
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ struct CaptureView {
|
|||
std::vector<ScanWaypointView> 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;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <memory>
|
||||
|
|
@ -155,6 +156,7 @@ struct Application::Impl {
|
|||
std::unique_ptr<CalibrationRoutine> calib;
|
||||
unsigned last_diag_seq = 0;
|
||||
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).
|
||||
bool diag_running_ = false;
|
||||
|
|
@ -355,6 +357,7 @@ struct Application::Impl {
|
|||
// scheduler that mutates it, so no locking is needed).
|
||||
s.capture.scan_from_file = !cfg.scan.grid_file.empty();
|
||||
s.capture.scan_pitch = t.pitch_present;
|
||||
s.capture.scan_error = scan_load_error_;
|
||||
s.capture.scan_grid.clear();
|
||||
const auto& pts = grid.points();
|
||||
s.capture.scan_grid.reserve(pts.size());
|
||||
|
|
@ -415,9 +418,19 @@ struct Application::Impl {
|
|||
} else if (scheduler && scheduler->captureActive()) {
|
||||
a.active = true;
|
||||
a.title = "CAPTURE";
|
||||
a.status = s.capture.has_last
|
||||
? ("scanning · last " + s.capture.last_label)
|
||||
: "scanning…";
|
||||
const char* phase = scheduler->moving() ? "moving" : "dwell";
|
||||
if (scheduler->controlCode() == 1) {
|
||||
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.
|
||||
|
|
@ -472,6 +485,7 @@ struct Application::Impl {
|
|||
}
|
||||
|
||||
// Wait for completion (both axes READY again) or the homing timeout.
|
||||
bool homed = false;
|
||||
const auto deadline = std::chrono::steady_clock::now() + 65s;
|
||||
while (running && std::chrono::steady_clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(250ms);
|
||||
|
|
@ -484,15 +498,81 @@ struct Application::Impl {
|
|||
}
|
||||
if (allReady(t)) {
|
||||
LOG_INFO << "Gimbal homed (both axes READY)";
|
||||
homed = true;
|
||||
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.
|
||||
motor->sendCommand("SPEED Y 50000");
|
||||
motor->sendCommand("SPEED P 150000");
|
||||
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() {
|
||||
// The scheduler arms regardless, but a scan can't progress until the axes
|
||||
// are homed: before READY the firmware leaves coils disabled and clamps
|
||||
|
|
@ -651,6 +731,11 @@ struct Application::Impl {
|
|||
}
|
||||
LOG_INFO << "gimbal raw -> " << 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" ||
|
||||
sub == "disable" || sub == "speed" || sub == "setpos" || sub == "status") {
|
||||
// Passthrough to the (case-insensitive) firmware verb + args.
|
||||
|
|
@ -874,6 +959,7 @@ struct Application::Impl {
|
|||
try {
|
||||
grid = ScanGrid::fromConfig(cfg.scan);
|
||||
} catch (const std::exception& e) {
|
||||
scan_load_error_ = e.what();
|
||||
LOG_WARN << "scan grid load failed (" << e.what() << "); auto-sweep disabled";
|
||||
}
|
||||
LOG_INFO << "scan grid: " << grid.size() << " waypoints";
|
||||
|
|
@ -904,7 +990,15 @@ struct Application::Impl {
|
|||
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();
|
||||
|
||||
LOG_INFO << "Entering control loop (type 'exit' to quit)";
|
||||
|
|
|
|||
|
|
@ -95,8 +95,10 @@ void CaptureScheduler::tick() {
|
|||
const ScanPoint& p = grid_.current();
|
||||
yaw_target_ = geometry_.yaw.toCounts(p.yaw_deg);
|
||||
pitch_target_ = geometry_.pitch.toCounts(p.pitch_deg);
|
||||
LOG_TRACE_CAT(LogCat::Control)
|
||||
<< "sweep -> grid yaw=" << p.yaw_deg << " pitch=" << p.pitch_deg;
|
||||
// One INFO line per move so the operator can follow the scan in the
|
||||
// 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.
|
||||
try {
|
||||
yaw_target_ = geometry_.yaw.toCounts(std::stod(target_heading_));
|
||||
|
|
@ -104,7 +106,7 @@ void CaptureScheduler::tick() {
|
|||
return; // bad heading; wait for a valid one
|
||||
}
|
||||
pitch_target_ = t.pitch.xenc;
|
||||
LOG_TRACE_CAT(LogCat::Control) << "directed -> heading " << target_heading_;
|
||||
LOG_INFO << "directed move: heading " << target_heading_ << " (deg)";
|
||||
}
|
||||
sendMove();
|
||||
moving_ = true;
|
||||
|
|
@ -117,6 +119,10 @@ void CaptureScheduler::tick() {
|
|||
LOG_TRACE_CAT(LogCat::Control)
|
||||
<< "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc << "; triggering";
|
||||
if (camera_.trigger()) {
|
||||
if (control_code_ == 0)
|
||||
LOG_INFO << "captured waypoint " << (grid_.index() + 1) << "/" << grid_.size();
|
||||
else
|
||||
LOG_INFO << "captured (directed)";
|
||||
moving_ = false;
|
||||
if (control_code_ == 0) grid_.advance();
|
||||
resetTimer();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
#include "fgc/Config.h"
|
||||
#include "fgc/Paths.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#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.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);
|
||||
{
|
||||
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).
|
||||
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)
|
||||
throw std::runtime_error("Parse error in config file " + path + " at line " +
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ DumpData parseDump(const std::string& block) {
|
|||
ax.has_encoder = asInt(get(m, "has_encoder", "0")) != 0;
|
||||
ax.lim_neg = asInt(get(m, "lim_neg", "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.speed = asInt(get(m, "speed", "0"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,10 @@ Element cameraPanel(const CaptureView& c) {
|
|||
hbox({text("capture ") | dim, active, filler(),
|
||||
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) {
|
||||
long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch()).count();
|
||||
|
|
@ -145,6 +149,16 @@ Element cameraDetailPanel(const CaptureView& c) {
|
|||
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()) {
|
||||
rows.push_back(text("(no scan grid defined — auto-sweep disabled)") | dim);
|
||||
return window(text(" SCAN GRID (c/Esc:close) ") | bold | color(Color::Cyan),
|
||||
|
|
@ -573,6 +587,7 @@ void TuiUi::refreshLoop() {
|
|||
while (running_) {
|
||||
std::this_thread::sleep_for(100ms);
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -602,6 +617,8 @@ void TuiUi::uiLoop() {
|
|||
text(" " + s.header.tower + " ") | bold,
|
||||
text("build " + s.header.build) | dim,
|
||||
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(" up " + std::to_string(s.header.uptime_ms / 1000) + "s ") | dim,
|
||||
});
|
||||
|
|
@ -616,7 +633,7 @@ void TuiUi::uiLoop() {
|
|||
bottom = hbox({
|
||||
keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"),
|
||||
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(":", "Cmd"), keyHint("?", "Help"), filler(), keyHint("q", "Quit"),
|
||||
});
|
||||
|
|
@ -712,6 +729,10 @@ void TuiUi::uiLoop() {
|
|||
overlay = (overlay == Overlay::Cameras) ? Overlay::None : Overlay::Cameras;
|
||||
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 (c == "j") { help_sel = (help_sel + 1) % 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
|
||||
// atomic pointer lets stop()/refreshLoop() reach it.
|
||||
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);
|
||||
if (running_) screen.Loop(root);
|
||||
screen_.store(nullptr);
|
||||
|
|
|
|||
Loading…
Reference in New Issue