From 5643e909d4b0a2b08c21488b50dcded629f5d1c3 Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Mon, 29 Jun 2026 17:28:56 +0200 Subject: [PATCH] Added expanded camera view with scan grid coordinates --- include/fgc/CaptureScheduler.h | 7 ++-- include/fgc/ScanGrid.h | 1 + include/fgc/ui/UiSnapshot.h | 12 +++++++ src/core/Application.cpp | 19 ++++++++++ src/core/CaptureScheduler.cpp | 17 ++++++--- src/ui/TuiUi.cpp | 63 ++++++++++++++++++++++++++++++++-- tests/test_scheduler.cpp | 34 ++++++++++++++++++ 7 files changed, 144 insertions(+), 9 deletions(-) diff --git a/include/fgc/CaptureScheduler.h b/include/fgc/CaptureScheduler.h index 3bec674..d169012 100644 --- a/include/fgc/CaptureScheduler.h +++ b/include/fgc/CaptureScheduler.h @@ -75,9 +75,10 @@ private: int control_code_ = 0; std::string target_heading_ = "0"; - bool moving_ = false; // a MOVE has been issued, awaiting settle - long yaw_target_ = 0; // current target, encoder counts - long pitch_target_ = 0; + bool moving_ = false; // a MOVE has been issued, awaiting settle + bool pitch_present_ = false; // a P: segment was seen in telemetry (2-axis rig) + long yaw_target_ = 0; // current target, encoder counts + long pitch_target_ = 0; }; } // namespace fgc diff --git a/include/fgc/ScanGrid.h b/include/fgc/ScanGrid.h index 2beb973..be4e79d 100644 --- a/include/fgc/ScanGrid.h +++ b/include/fgc/ScanGrid.h @@ -31,6 +31,7 @@ public: bool empty() const { return points_.empty(); } std::size_t size() const { return points_.size(); } + std::size_t index() const { return index_; } // live cursor position const ScanPoint& current() const { return points_[index_]; } const std::vector& points() const { return points_; } diff --git a/include/fgc/ui/UiSnapshot.h b/include/fgc/ui/UiSnapshot.h index 5b3419e..5825eb8 100644 --- a/include/fgc/ui/UiSnapshot.h +++ b/include/fgc/ui/UiSnapshot.h @@ -76,12 +76,24 @@ struct SensorsView { SensorGroup dht; // DHT11: ambient temperature + humidity }; +// 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 +}; + struct CaptureView { bool present = false; bool active = false; double image_rate = 0.0; // img/s int camera_count = 0; std::vector labels; + // Defined scan grid (ControlCode 0 auto-sweep 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) // Last published capture (from ImagePipeline::lastEvent()). bool has_last = false; std::string last_label; diff --git a/src/core/Application.cpp b/src/core/Application.cpp index 8926224..9549c6d 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -351,6 +351,15 @@ struct Application::Impl { s.capture.image_rate = scheduler ? scheduler->imageRate() : 0.0; s.capture.camera_count = camera ? camera->cameraCount() : 0; s.capture.labels = cfg.camera.labels; + // Defined scan grid + live cursor (read on the control thread, same as the + // 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_grid.clear(); + const auto& pts = grid.points(); + s.capture.scan_grid.reserve(pts.size()); + for (std::size_t i = 0; i < pts.size(); ++i) + s.capture.scan_grid.push_back({pts[i].yaw_deg, pts[i].pitch_deg, i == grid.index()}); if (pipeline) { if (auto ev = pipeline->lastEvent()) { s.capture.has_last = true; @@ -485,6 +494,16 @@ struct Application::Impl { } 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 + // MOVE targets to a coarse bench guard, so no waypoint is ever reached and + // the move/settle cycle stalls silently. Refuse with a clear message + // instead. (Single-axis builds report no P: segment, so skip pitch.) + MotorTelemetry t = motor->telemetry(); + if (!t.yaw.ready() || (t.pitch_present && !t.pitch.ready())) { + LOG_WARN << "Capture not started: gimbal not homed (run init/home first)"; + return; + } camera->start(); scheduler->setCaptureActive(true); LOG_INFO << "Capture started"; diff --git a/src/core/CaptureScheduler.cpp b/src/core/CaptureScheduler.cpp index 1e3c8c5..ab9ef16 100644 --- a/src/core/CaptureScheduler.cpp +++ b/src/core/CaptureScheduler.cpp @@ -3,7 +3,6 @@ #include "fgc/Logger.h" #include -#include #include namespace fgc { @@ -46,12 +45,21 @@ long long CaptureScheduler::elapsedMs() const { return now_ms_() - timer_start_; void CaptureScheduler::resetTimer() { timer_start_ = now_ms_(); } void CaptureScheduler::sendMove() { - motor_.sendCommand("MOVE " + std::to_string(yaw_target_) + "," + std::to_string(pitch_target_)); + // On a 1-axis (yaw-only) rig the firmware rejects the two-axis `MOVE y,p` + // form ("ERR axis not present") and moves nothing, so address yaw alone. + if (pitch_present_) { + motor_.sendCommand("MOVE " + std::to_string(yaw_target_) + "," + + std::to_string(pitch_target_)); + } else { + motor_.sendCommand("MOVE Y " + std::to_string(yaw_target_)); + } } bool CaptureScheduler::settledAt(const MotorTelemetry& t) const { - return t.yaw.standstill && t.pitch.standstill && - std::labs(t.yaw.xenc - yaw_target_) <= kSettleTolCounts && + bool yaw_ok = t.yaw.standstill && std::labs(t.yaw.xenc - yaw_target_) <= kSettleTolCounts; + // A 1-axis rig never reports pitch standstill, so don't gate the capture on it. + if (!t.pitch_present) return yaw_ok; + return yaw_ok && t.pitch.standstill && std::labs(t.pitch.xenc - pitch_target_) <= kSettleTolCounts; } @@ -70,6 +78,7 @@ void CaptureScheduler::tick() { // 2. Telemetry. MotorTelemetry t = motor_.telemetry(); + pitch_present_ = t.pitch_present; // 3. Capture cycle. Only ControlCode 0 (sweep) and 1 (directed) act. if (control_code_ != 0 && control_code_ != 1) return; diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index 17be263..e2c62e1 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -130,6 +130,58 @@ Element cameraPanel(const CaptureView& c) { return panel("CAMERA", Color::Blue, vbox(std::move(rows))); } +// Expanded camera view ('c'): the defined auto-sweep scan grid with the live +// cursor highlighted. Long grids wrap into side-by-side columns so the whole +// list stays visible. +Element cameraDetailPanel(const CaptureView& c) { + Element active = c.active ? (text(" CAPTURING ") | color(Color::Green) | bold) + : (text(" idle ") | dim); + std::vector rows = { + hbox({text("capture ") | dim, active, filler(), + text(std::to_string(c.image_rate) + " img/s") | dim}), + hbox({text("source ") | dim, + text(c.scan_from_file ? "scan grid file (CSV)" : "generated grid")}), + hbox({text("waypoints ") | dim, text(std::to_string(c.scan_grid.size()))}), + separator(), + }; + + 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), + vbox(std::move(rows))); + } + + // " # | yaw / pitch", current row inverted. Pitch column dropped on 1-axis. + constexpr size_t kRows = 18; // rows per column before wrapping + std::vector cols; + std::vector col; + auto flushColumn = [&] { + if (col.empty()) return; + if (!cols.empty()) cols.push_back(text(" ")); + cols.push_back(vbox(std::move(col))); + col.clear(); + }; + for (size_t i = 0; i < c.scan_grid.size(); ++i) { + const ScanWaypointView& w = c.scan_grid[i]; + std::string idx = std::to_string(i + 1); + if (idx.size() < 3) idx = std::string(3 - idx.size(), ' ') + idx; + std::string coords = c.scan_pitch + ? formatDegrees(w.yaw_deg) + " / " + formatDegrees(w.pitch_deg) + : formatDegrees(w.yaw_deg); + Element line = hbox({text(idx + " ") | dim, text(coords)}); + if (w.current) line = line | inverted; + col.push_back(line); + if (col.size() == kRows) flushColumn(); + } + flushColumn(); + + rows.push_back(hbox({text(" # ") | dim, + text(c.scan_pitch ? "yaw / pitch" : "yaw") | dim})); + rows.push_back(hbox(std::move(cols))); + return window(text(" SCAN GRID (c/Esc:close) ") | bold | color(Color::Cyan), + vbox(std::move(rows))); +} + Element connPanel(const ConnView& v) { Element mqtt = !v.mqtt_enabled ? (text(" disabled ") | dim) : v.mqtt_connected ? (text(" connected ") | color(Color::Green) | bold) @@ -528,7 +580,7 @@ void TuiUi::refreshLoop() { void TuiUi::uiLoop() { std::string cmd_buffer; bool command_mode = false; - enum class Overlay { None, Help, Gimbal, Sensors }; + enum class Overlay { None, Help, Gimbal, Sensors, Cameras }; Overlay overlay = Overlay::None; // which takeover panel owns the main area int help_sel = 0; bool gimbal_dump_requested = false; // auto-pull a dump the first time @@ -563,7 +615,8 @@ void TuiUi::uiLoop() { } else { bottom = hbox({ keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"), - keyHint("g", "Gimbal"), keyHint("i", "IMU"), keyHint("r", "Refresh"), + keyHint("g", "Gimbal"), keyHint("i", "IMU"), keyHint("c", "Scan"), + 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"), }); @@ -580,6 +633,8 @@ void TuiUi::uiLoop() { gimbalDetailPanel(s.gimbal, s.dump, s.calib) | flex, bottom}); case Overlay::Sensors: return vbox({header, separator(), imuDetailPanel(s.imu) | flex, bottom}); + case Overlay::Cameras: + return vbox({header, separator(), cameraDetailPanel(s.capture) | flex, bottom}); default: { // Activity strip sits between the log and the key bar; shown only // once a special op has run or is running, else it costs no space. @@ -653,6 +708,10 @@ void TuiUi::uiLoop() { overlay = (overlay == Overlay::Sensors) ? Overlay::None : Overlay::Sensors; return true; } + if (c == "c") { + overlay = (overlay == Overlay::Cameras) ? Overlay::None : Overlay::Cameras; + return true; + } 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; } diff --git a/tests/test_scheduler.cpp b/tests/test_scheduler.cpp index b413a84..87b0b46 100644 --- a/tests/test_scheduler.cpp +++ b/tests/test_scheduler.cpp @@ -12,6 +12,8 @@ namespace { struct FakeMotor : IMotorController { MotorTelemetry tel; std::vector cmds; + FakeMotor() { tel.pitch_present = true; } // 2-axis rig by default; 1-axis tests clear this + void start() override {} void stop() override {} void sendCommand(const std::string& c) override { cmds.push_back(c); } @@ -107,6 +109,38 @@ TEST_CASE("CaptureScheduler sweeps the scan grid with MOVE + settle + trigger") CHECK(motor.cmds.back() == "MOVE 900,0"); } +TEST_CASE("CaptureScheduler scans a 1-axis (yaw-only) rig with yaw-only MOVE/settle") { + long long clock = 0; + FakeMotor motor; + motor.tel.pitch_present = false; // yaw-only gimbal: no P: segment in telemetry + FakeCamera cam; + FakeChannel chan; + ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}}); + + CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); + sch.setCaptureActive(true); + chan.next.control_code_available = true; + chan.next.control_code = 0; + + // The MOVE must address yaw alone, not the two-axis form a 1-axis firmware rejects. + clock = 1600; + sch.tick(); + REQUIRE(motor.cmds.size() == 1); + CHECK(motor.cmds[0] == "MOVE Y 0"); + + // Settle on yaw only (pitch never reports standstill on this rig) -> capture fires. + motor.tel.yaw.xenc = 0; + motor.tel.yaw.standstill = true; + motor.tel.yaw.state = AxisState::Ready; + clock = 1700; + sch.tick(); + CHECK(cam.triggers == 1); + + clock = 2800; + sch.tick(); + CHECK(motor.cmds.back() == "MOVE Y 900"); +} + TEST_CASE("CaptureScheduler ControlCode 1 drives yaw to the target heading") { long long clock = 0; FakeMotor motor;