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_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"
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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_;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)";
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue