fwt_software/src/ui/TuiUi.cpp

1711 lines
85 KiB
C++

#include "fgc/ui/TuiUi.h"
#include <ctime>
#include "fgc/ChannelTopics.h"
#include "fgc/DumpParser.h"
#include "fgc/HelpText.h"
#include "fgc/Logger.h"
#include "fgc/ui/Scroll.h"
#include <algorithm>
#include <cctype>
#include <array>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <cstdio>
#include <map>
#include <sstream>
#include <stdexcept>
#include <vector>
#include <ftxui/component/component.hpp>
#include <ftxui/component/event.hpp>
#include <ftxui/component/screen_interactive.hpp>
#include <ftxui/dom/elements.hpp>
#include <ftxui/dom/node.hpp>
#include <ftxui/dom/requirement.hpp>
#include <ftxui/screen/box.hpp>
#include <ftxui/screen/color.hpp>
namespace fgc {
using namespace ftxui;
namespace {
std::string upperStr(std::string s) {
for (char& c : s) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return s;
}
Color toColor(UiColor c) {
switch (c) {
case UiColor::Green: return Color::Green;
case UiColor::Yellow: return Color::Yellow;
case UiColor::Red: return Color::Red;
case UiColor::Cyan: return Color::Cyan;
case UiColor::Dim: return Color::GrayDark;
case UiColor::Default:
default: return Color::Default;
}
}
// A bordered panel with a colored, bold title.
Element panel(const std::string& title, Color title_color, Element body) {
return window(text(" " + title + " ") | bold | color(title_color), std::move(body)) | flex;
}
// ---- Scrollable panes -------------------------------------------------------
//
// `yframe` clips its child to the available box and scrolls to reveal whichever
// element carries the "selected box". Nothing sets one by default, so a bare
// `vbox(rows) | yframe` renders the TOP of the content and silently drops the
// rest - which is what used to make long waypoint lists and register dumps
// unreachable.
//
// The anchor has to be applied DIRECTLY to the frame's child: focusPosition
// leaves requirement_.selection at NORMAL, and vbox/hbox only propagate a
// child's selected box when its selection is strictly greater than their own
// (which starts at NORMAL). Only focus(), which sets FOCUSED, would survive
// being buried inside a row - and it is unusable here because the rows have
// wildly different heights (a 3-line axis row, a 1-line separator, the whole
// scan grid as one ~20-line element), so a per-row cursor would scroll by 1, 3
// or 40 lines a keypress.
// Capture a child's REQUESTED height. reflect() cannot do this: its Render
// intersects the box with the screen stencil, so inside a frame it hands back
// the visible height, and content/view would always compare equal.
Decorator reflectHeight(int& out) {
class Impl : public Node {
public:
Impl(Element child, int& out) : Node(unpack(std::move(child))), out_(out) {}
void ComputeRequirement() override {
Node::ComputeRequirement();
requirement_ = children_[0]->requirement();
out_ = requirement_.min_y;
}
void SetBox(Box box) override {
Node::SetBox(box);
children_[0]->SetBox(box);
}
private:
int& out_;
};
return [&out](Element child) { return std::make_shared<Impl>(std::move(child), out); };
}
struct ScrollState {
int top = 0; // first body LINE we want visible
bool follow = false; // pinned to the newest (bottom) line instead
// Last "new lines" stamp seen (followNew): when it moves, the pane snaps back
// to the bottom - unless the screen is paused, which is how one reads back.
unsigned long long seen = 0;
bool seen_any = false;
// Measured by the previous frame: the body's full requested height, and the
// visible height of the pane it is framed into.
int content_h = 0;
Box view{};
int contentH() const { return content_h; }
int viewH() const { return view.y_max - view.y_min + 1; }
bool scrollable() const { return scrollMax(contentH(), viewH()) > 0; }
};
// New lines arrived in a pane that streams them (the log, the message history,
// a fresh dump): show the newest. Paused, nothing moves - pausing is how the
// operator reads further back.
void followNew(ScrollState& st, unsigned long long stamp, bool paused) {
// First sight: a view opens where it opens (the log is already following).
if (!st.seen_any) { st.seen = stamp; st.seen_any = true; return; }
if (stamp == st.seen) return;
st.seen = stamp;
if (!paused) st.follow = true;
}
// Frame `body` so line `st.top` sits at the top of the pane.
//
// Frame::SetBox CENTRES the viewport on the anchor (dy = selected.y_min -
// view/2 + focused/2, then clamped), so the anchor is pushed half a screen down
// to cancel that and land on `top` exactly.
Element scrollableBody(Element body, ScrollState& st) {
st.top = scrollClamp(st.top, st.contentH(), st.viewH());
Decorator anchor = st.follow
? focusPositionRelative(0.f, 1.f)
: focusPosition(0, st.top + st.viewH() / 2);
return std::move(body) | reflectHeight(st.content_h) | anchor | vscroll_indicator | yframe |
reflect(st.view);
}
// Overlay window title. The scroll keys are advertised only while there is
// something off-screen, so the hint doubles as the "there is more below" cue on
// a pane that happens to fit.
Element overlayTitle(const std::string& name, const std::string& keys, const ScrollState& st,
Color c = Color::Cyan) {
std::string hint = keys;
if (st.scrollable()) hint += " PgUp/PgDn:scroll";
return text(" " + name + " (" + hint + ") ") | bold | color(c);
}
Element cameraDetailTitle(const ScrollState& st) {
return overlayTitle("CAMERAS", "c/Esc:close", st);
}
// Fixed-decimal number. Bare std::to_string on a double gives six decimals, so
// an image rate of 0.2 rendered as "0.200000 img/s".
std::string num(double v, int decimals) {
char b[32];
std::snprintf(b, sizeof(b), "%.*f", decimals, v);
return b;
}
// "label value" with the label in a fixed-width gutter, so the value columns
// line up across every panel instead of being hand-spaced per panel.
constexpr int kLabelW = 9; // compact dashboard panels
Element labelRow(const std::string& label, Element value, int width = kLabelW) {
return hbox({text(label) | dim | size(WIDTH, EQUAL, width), std::move(value) | flex});
}
// "key" + label pair for the nano-style bottom bar.
Element keyHint(const std::string& key, const std::string& label) {
return hbox({text(" " + key + " ") | inverted, text(" " + label + " ")});
}
// Which overlay owns the body. Declared here so keyBar() can switch on it; the
// render loop keeps the authoritative value.
enum class Overlay { None, Help, Gimbal, Sensors, Cameras, Conn };
// ---- heading setup (in the activity strip) ---------------------------------
//
// Ties encoder counts to true bearings by having the operator aim at landmarks
// they can identify on a map. The marks and the fit belong to the program
// (`gimbal heading setup|mark|fit|cancel`), so the same steps work headless and
// over MQTT; what lives here is only the aiming - arrow keys in three step sizes.
enum class JogPhase { Coarse, Fine, VeryFine };
// The finest step is 25 counts, not 1. ENC_CONST makes X_ENC advance in
// 12.8-microstep quanta, so a smaller step moves the motor but records a position
// the gimbal cannot reproduce - and 25 counts is already ~0.05 deg on yaw, half
// the rig's pointing uncertainty, so it is never the limiting factor.
constexpr long kVeryFineCounts = 25;
constexpr double kCoarseTravelPct = 5.0;
constexpr double kFineTravelPct = 0.5;
const char* jogPhaseName(JogPhase p) {
switch (p) {
case JogPhase::Coarse: return "coarse";
case JogPhase::Fine: return "fine";
case JogPhase::VeryFine: return "very fine";
}
return "?";
}
// Step size in counts for a phase, given that axis's homed endstop-to-endstop
// travel. Falls back to the very-fine step when travel is unknown (no dump yet),
// which is slow but never wild.
long jogStepCounts(JogPhase p, long travel_counts) {
if (p == JogPhase::VeryFine || travel_counts <= 0) return kVeryFineCounts;
const double pct = (p == JogPhase::Coarse) ? kCoarseTravelPct : kFineTravelPct;
long step = static_cast<long>(pct / 100.0 * static_cast<double>(travel_counts));
return step > kVeryFineCounts ? step : kVeryFineCounts;
}
// Angular size of a step, when the yaw scale is known. Shown alongside the count
// so the operator can judge the phase without doing the arithmetic.
std::string jogStepLabel(JogPhase p, long travel_counts, double counts_per_deg) {
const long step = jogStepCounts(p, travel_counts);
std::string out = std::to_string(step) + " counts";
if (std::fabs(counts_per_deg) > 1e-9)
out += " (~" + num(std::fabs(step / counts_per_deg), 3) + " deg)";
return out;
}
// The bottom bar, contextual: the overview advertises everyday operations and
// the view keys, and each view advertises what its own body responds to.
//
// This changes only what is ADVERTISED. Every binding still works everywhere -
// 'a' switches to auto from inside the gimbal view exactly as before - so the bar
// is a hint about what is useful here, not a restriction.
//
// flexbox (Wrap::Wrap by default) rather than hbox: the overview set is wider
// than an 80-column terminal, and hbox silently truncated it mid-word ("q Qui").
Element keyBar(Overlay overlay, bool manual, bool heading_setup, bool pitch_present) {
// ←→↑↓, or just ←→ on a yaw-only tower.
const char* arrows = pitch_present ? "\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93"
: "\xE2\x86\x90\xE2\x86\x92";
Elements e;
if (heading_setup) {
// Replaces the common set: mode changes are refused during heading
// setup, so advertising them here would only mislead.
e.push_back(keyHint(arrows, "Aim"));
e.push_back(keyHint("Tab", "Step"));
e.push_back(keyHint("Enter", "Mark"));
e.push_back(keyHint("Spc", "Snap"));
e.push_back(keyHint("f", "Fit"));
e.push_back(keyHint("Esc", "Cancel"));
} else {
// The modes are wanted from every view, so they lead everywhere.
e.push_back(keyHint("a", "Auto"));
e.push_back(keyHint("p", "Pan"));
e.push_back(keyHint("m", "Manual"));
switch (overlay) {
case Overlay::None:
e.push_back(keyHint("h", "Home"));
e.push_back(keyHint("g s c n", "Views"));
e.push_back(keyHint("f", "Pause"));
e.push_back(keyHint("PgUp/Dn", "Log"));
break;
case Overlay::Gimbal:
e.push_back(keyHint("d", "Dump"));
e.push_back(keyHint("PgUp/Dn", "Scroll"));
break;
case Overlay::Help:
case Overlay::Sensors:
case Overlay::Cameras:
case Overlay::Conn:
e.push_back(keyHint("PgUp/Dn", "Scroll"));
break;
}
// Hand aiming is refused outside manual, so stop advertising it rather
// than inviting a keypress that only warns.
if (manual) {
e.push_back(keyHint(arrows, "Nudge"));
e.push_back(keyHint("Spc", "Snap"));
e.push_back(keyHint("r", "Capture"));
}
if (overlay != Overlay::None) e.push_back(keyHint("Esc", "Close"));
}
e.push_back(keyHint(":", "Cmd"));
e.push_back(keyHint("?", "Help"));
e.push_back(keyHint("q", "Quit"));
return flexbox(std::move(e));
}
// The "are you sure?" block that replaces the key bar when q is pressed.
//
// q and Ctrl-b d are one keystroke apart and mean opposite things when the tower
// is operated over ssh, and nothing on screen said so at the moment of choosing
// (scripts/fgc resorts to warning about it in its tmux status line). This asks.
Element exitPrompt(bool can_background) {
Element bg = can_background
? hbox({text(" b ") | bold,
text("Background \xE2\x80\x94 detach, leave it running "
"(same as Ctrl-b d)")})
: hbox({text(" b ") | dim,
text("Background \xE2\x80\x94 unavailable, needs scripts/fgc") | dim});
return vbox({
text(" Exit the dashboard? ") | bold | color(Color::Black) | bgcolor(Color::Yellow),
hbox({text(" q ") | bold,
text("Quit \xE2\x80\x94 stops capture, closes the camera, ends the program")}),
bg,
hbox({text(" Esc ") | bold, text("Cancel") | dim}),
}) | border | color(Color::Yellow);
}
Element badge(const std::string& s, bool on, Color on_color) {
auto e = text(" " + s + " ");
return on ? (e | color(on_color) | bold) : (e | dim);
}
Element axisRow(const AxisView& a) {
Element state = text(std::string(" ") + axisStateLabel(a.state) + " ")
| color(toColor(axisStateColor(a.state))) | bold;
Element heading = text(formatDegrees(a.deg)) | bold;
return vbox({
hbox({text(a.label + " ") | bold, state, filler(),
text("→ " + formatDegrees(a.target_deg)) | dim}),
hbox({text(" "), heading,
text(" x=" + std::to_string(a.xactual)) | dim,
text(" enc=" + std::to_string(a.xenc)) | dim}),
hbox({text(" "),
badge("STILL", a.standstill, Color::Cyan),
badge("MOVE", a.moving, Color::Cyan),
badge("STALL", a.deviation, Color::Red),
badge("OT", a.overtemp, Color::Red),
badge("L", a.endstop_l, Color::Cyan),
badge("R", a.endstop_r, Color::Cyan)}),
});
}
Element gimbalPanel(const GimbalView& g) {
std::vector<Element> rows;
if (!g.present) rows.push_back(text("link down") | color(Color::Red) | bold);
rows.push_back(axisRow(g.yaw));
if (g.pitch_present) {
rows.push_back(separator());
rows.push_back(axisRow(g.pitch));
}
return panel("[g] GIMBAL", Color::Cyan, vbox(std::move(rows)));
}
Element sensorsPanel(const SensorsView& s) {
// One labelled subsection per physical sensor: a title + status header, then
// its readings. Keeps MTi (orientation/device temp) and the ambient env
// sensor (SHT41) clearly separated.
auto group = [](const SensorGroup& g, Element trailing) {
std::vector<Element> rows;
Element status = g.present ? (text(g.status) | color(Color::Green))
: (text(g.status) | dim);
rows.push_back(hbox({text(g.title) | bold, text(" "), status, filler(),
std::move(trailing)}));
for (const auto& f : g.fields) {
Element val = text(f.value + (f.unit.empty() ? "" : " " + f.unit));
val = f.present ? (val | bold) : (val | dim);
rows.push_back(hbox({text(" "), text(f.label) | dim, filler(), val}));
}
return vbox(std::move(rows));
};
// The title already says "[s]"; repeating "s to expand" in the body was noise.
// A misconfigured MTi reports plausible-looking numbers about the wrong axes,
// so the readings themselves can never reveal it - it has to be said here.
Element imu_badge = s.imu_config_alert
? (text(" IMU CFG ") | color(Color::Red) | bold)
: text("");
Element fan_badge = s.fan_alert ? (text(" FAN ") | color(Color::Red) | bold) : text("");
Element cpu_badge = s.cpu_badge.empty()
? text("")
: (text(" " + s.cpu_badge + " ") |
color(s.cpu_badge_red ? Color::Red : Color::Yellow) | bold);
return panel("[s] SENSORS", Color::Magenta,
vbox({group(s.imu, std::move(imu_badge)), separator(),
group(s.env, std::move(fan_badge)), separator(),
group(s.host, std::move(cpu_badge))}));
}
// "max 301.5 °C median 18.7 °C cal 0" for the last thermal picture.
Element thermalLine(const CaptureView& c) {
if (!c.thermal_stats_ok)
return text("no valid pixels cal " + std::to_string(c.thermal_cal)) | color(Color::Yellow);
return hbox({text("max " + num(c.thermal_max_c, 1) + " \xC2\xB0" "C") | bold,
text(" median " + num(c.thermal_med_c, 1) + " \xC2\xB0" "C") | dim,
text(" cal " + std::to_string(c.thermal_cal)) | dim});
}
Element cameraPanel(const CaptureView& c) {
std::string labels;
for (size_t i = 0; i < c.labels.size(); ++i)
labels += (i ? "," : "") + c.labels[i];
Element active = c.active ? (text(" CAPTURING ") | color(Color::Green) | bold)
: (text(" idle ") | dim);
std::vector<Element> rows = {
labelRow("cameras", hbox({text(std::to_string(c.camera_count) + " "),
text(labels) | dim})),
labelRow("capture", hbox({active, filler(),
text(num(c.image_rate, 2) + " img/s") | dim})),
};
if (!c.scan_error.empty())
rows.push_back(labelRow("scan", hbox({text(" GRID LOAD FAILED ") | color(Color::Red) | bold,
text(" c for details") | dim})));
{
// One line per camera: "<label> <profile>", so which camera is on which
// profile is visible without opening the expanded view.
std::string on;
for (const auto& v : c.config.cameras) {
if (!v.enabled) continue;
on += (on.empty() ? "" : " ") + v.label + " " + v.profile;
}
rows.push_back(labelRow("profile", text(on.empty() ? "\xE2\x80\x94" : on) |
color(Color::Cyan)));
}
if (c.has_last) {
long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
rows.push_back(labelRow("last", hbox({
text(c.last_label + " "),
text(formatDegrees(c.last_heading_deg) + " / " +
formatDegrees(c.last_pitch_deg) + " ") | dim,
text(formatTimeAgo(now, c.last_ts_ms)) | dim})));
} else {
rows.push_back(labelRow("last", text("\xE2\x80\x94") | dim));
}
if (c.has_thermal) rows.push_back(labelRow("thermal", thermalLine(c)));
for (size_t i = 0; i < c.devices.size(); ++i)
if (!c.devices[i].offline.empty())
rows.push_back(labelRow("offline", text((i < c.labels.size() ? c.labels[i] : "?") +
" " + c.devices[i].offline) |
color(Color::Yellow)));
return panel("[c] CAMERAS", Color::Blue, vbox(std::move(rows)));
}
// Expanded camera view ('c'): the full camera system — identity, live sensor
// telemetry, imaging config, acquisition health, encoding/output — with the
// auto-sweep scan grid below. The whole body scrolls (yframe) if it overflows.
Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
auto row = [](const std::string& k, Element v) { return labelRow(k, std::move(v), 14); };
std::vector<Element> rows;
// --- Status ---
Element active = c.active ? (text(" CAPTURING ") | color(Color::Green) | bold)
: (text(" idle ") | dim);
rows.push_back(hbox({text("capture ") | dim, active, filler(),
text(num(c.image_rate, 3) + " img/s") | dim}));
rows.push_back(row("images", text(std::to_string(c.images_saved) + " saved this session")));
// --- Devices: identity + live telemetry ---
rows.push_back(separator());
rows.push_back(text("DEVICES") | bold | color(Color::Blue));
if (c.devices.empty()) rows.push_back(text("(no camera device info)") | dim);
for (size_t i = 0; i < c.devices.size(); ++i) {
const CameraDeviceView& d = c.devices[i];
std::string label = i < c.labels.size() ? c.labels[i] : ("cam" + std::to_string(i));
Element st = !d.offline.empty() ? (text("offline") | color(Color::Red))
: d.streaming ? (text("streaming") | color(Color::Green))
: (text("stopped") | color(Color::Yellow));
rows.push_back(hbox({text(label + " ") | bold,
text(d.model.empty() ? "?" : d.model) | color(Color::Cyan),
text(" " + d.id) | dim, filler(), st}));
rows.push_back(row(" serial", text(d.serial.empty() ? "—" : d.serial) | dim));
if (!d.offline.empty())
rows.push_back(row(" offline", text(d.offline) | color(Color::Red)));
rows.push_back(row(" frame", text(std::to_string(d.width) + "x" + std::to_string(d.height) +
" " + num(d.payload_bytes / 1048576.0, 1) + " MB")));
rows.push_back(row(" exposure", text(num(d.exposure_us, 0) + " us gain " +
num(d.gain_db, 1) + " dB")));
rows.push_back(row(" sensor", text(num(d.temperature_c, 1) + " °C " +
num(d.actual_fps, 2) + " fps")));
rows.push_back(row(" frames", hbox({
text(std::to_string(d.frames_delivered) + " ok ") | color(Color::Green),
text(std::to_string(d.frames_dropped) + " dropped ") |
(d.frames_dropped ? color(Color::Red) : color(Color::Default)),
text(std::to_string(d.restarts) + " restarts") | dim,
})));
}
// --- Cameras: one block each, everything explicit ---
const CameraConfigView& cf = c.config;
rows.push_back(separator());
{
// Which backends: "Vimba X", "Vimba X + TarisIR".
std::string backends;
auto add = [&backends](const std::string& b) {
if (backends.find(b) == std::string::npos) backends += (backends.empty() ? "" : " + ") + b;
};
for (const auto& v : cf.cameras)
if (v.enabled) add(v.driver == "taris" ? "TarisIR" : "Vimba X");
rows.push_back(hbox({text("CAMERAS") | bold | color(Color::Blue),
text(cf.mock ? " MOCK (simulated)" : " " + backends) | dim}));
}
if (cf.cameras.empty()) rows.push_back(text("(no cameras configured)") | dim);
for (const auto& v : cf.cameras) {
rows.push_back(hbox({
text(v.label.empty() ? v.name : v.label) | bold,
text(" [" + v.name + "]") | dim,
text(" " + v.id) | dim,
filler(),
v.enabled ? (text("enabled") | color(Color::Green)) : (text("disabled") | dim),
}));
rows.push_back(row(" profile", hbox({
text(v.profile.empty() ? "\xE2\x80\x94" : v.profile) | color(Color::Cyan) | bold,
text(" " + v.profile_xml) | dim})));
{
std::string others;
for (const auto& p : v.profiles_available) others += (others.empty() ? "" : ", ") + p;
rows.push_back(row(" available", text(others.empty() ? "\xE2\x80\x94" : others) | dim));
}
rows.push_back(row(" settle", text(std::to_string(v.settle_wait_ms) +
" ms after standstill before keeping a frame")));
rows.push_back(row(" encoding",
text(v.driver == "taris"
? "JPEG XL lossless 16-bit counts effort " +
std::to_string(v.jxl_effort)
: "JPEG XL distance " + num(v.jxl_distance, 2) +
" effort " + std::to_string(v.jxl_effort))));
rows.push_back(row(" rotation", text(v.rotation_deg == 0
? std::string("none")
: std::to_string(v.rotation_deg) + " deg CW")));
}
rows.push_back(row("output", text(cf.output_dir.empty() ? "\xE2\x80\x94" : cf.output_dir) | dim));
// --- Last capture ---
if (c.has_last) {
long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
rows.push_back(row("last capture", text(c.last_label + " " +
formatDegrees(c.last_heading_deg) + " / " +
formatDegrees(c.last_pitch_deg) + " " +
formatTimeAgo(now, c.last_ts_ms))));
}
if (c.has_thermal) rows.push_back(row("last thermal", thermalLine(c)));
// --- Scan grid ---
rows.push_back(separator());
rows.push_back(hbox({text("SCAN GRID ") | bold | color(Color::Blue), filler(),
text(std::to_string(c.scan_grid.size()) + " waypoints") | dim}));
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(cameraDetailTitle(st),
scrollableBody(vbox(std::move(rows)), st));
}
if (c.scan_grid.empty()) {
rows.push_back(text("(no scan grid defined — auto-sweep disabled)") | dim);
return window(cameraDetailTitle(st),
scrollableBody(vbox(std::move(rows)), st));
}
// " # | yaw / pitch", current row inverted. Pitch column dropped on 1-axis.
// Cap the COLUMN count rather than the row count: past the cap the grid grows
// downward, where the pane's vertical scrolling can reach it. Wrapping at a
// fixed 18 rows grew it rightwards instead, off the edge of a screen that
// yframe does not clip and no key can pan.
constexpr size_t kMaxCols = 3; // ~66 columns wide: fits an 80-column terminal
constexpr size_t kMinRows = 18; // short grids keep the original compact look
const size_t kRows = gridRowsPerColumn(c.scan_grid.size(), kMaxCols, kMinRows);
std::vector<Element> cols;
std::vector<Element> 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(cameraDetailTitle(st), scrollableBody(vbox(std::move(rows)), st));
}
Element connPanel(const ConnView& v) {
Element mqtt = !v.mqtt_enabled ? (text(" disabled ") | dim)
: v.mqtt_connected ? (text(" connected ") | color(Color::Green) | bold)
: (text(" disconnected ") | color(Color::Red) | bold);
return panel("[n] CONNECTIVITY", Color::Green, vbox({
labelRow("MQTT", hbox({mqtt, filler(), text(v.broker) | dim})),
labelRow("tower", text(v.tower)),
labelRow("mode", hbox({text(v.mode), filler(),
text("StatusCode " + std::to_string(v.status_code)) | dim})),
}));
}
// Expanded connectivity view ('n'): broker state, the four topics with their last
// traffic, the counters, and a scrollable history of recent messages.
//
// The history is the point of the view. `dropped` in particular has no other
// route to the operator: MqttControlChannel::publish() discards a message when
// the link is down without queueing, retrying or logging, so a lost CamEvent -
// and with it an image the ground station never fetches - is otherwise invisible.
Element connDetailPanel(const ConnView& v, ScrollState& st) {
auto row = [](const std::string& k, Element val) { return labelRow(k, std::move(val), 12); };
const long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
std::vector<Element> rows;
// --- Broker ---
rows.push_back(text("BROKER") | bold | color(Color::Green));
Element state = !v.mqtt_enabled
? (text(" disabled - messages not sent ") | color(Color::Yellow) | bold)
: v.mqtt_connected
? (text(" connected ") | color(Color::Green) | bold)
: (text(" disconnected - retrying ") | color(Color::Red) | bold);
const bool really_up = v.mqtt_enabled && v.mqtt_connected && v.stats.last_connect_ms > 0;
rows.push_back(hbox({text("state ") | dim, state, filler(),
text(really_up ? "since " + formatTimeAgo(now, v.stats.last_connect_ms)
: "") | dim}));
rows.push_back(row("uri", text(v.broker.empty() ? "\xE2\x80\x94" : v.broker)));
rows.push_back(row("client id", text(v.tower)));
rows.push_back(row("settings", text("QoS 1 \xC2\xB7 retained (not replies) \xC2\xB7 "
"keep-alive 20 s \xC2\xB7 clean session") | dim));
rows.push_back(row("link", text(std::to_string(v.stats.connects) + " connect(s), last loss " +
formatTimeAgo(now, v.stats.last_loss_ms)) | dim));
// --- Counters ---
rows.push_back(separator());
rows.push_back(text("COUNTERS") | bold | color(Color::Green));
rows.push_back(row("published", hbox({
text(std::to_string(v.stats.published_ok) + " ok ") | color(Color::Green),
text(std::to_string(v.stats.published_dropped) + " dropped ") |
(v.stats.published_dropped ? color(Color::Red) : color(Color::Default)),
text(std::to_string(v.stats.published_failed) + " failed") |
(v.stats.published_failed ? color(Color::Red) : color(Color::Default)),
})));
rows.push_back(row("received", text(std::to_string(v.stats.received))));
if (v.stats.published_dropped)
rows.push_back(text("dropped = attempted while the link was down; not queued, not "
"retried, gone.") | color(Color::Red) | dim);
// --- Topics: last traffic per topic, newest wins ---
rows.push_back(separator());
rows.push_back(text("TOPICS") | bold | color(Color::Green));
struct TopicRow { const char* dir; const char* leaf; };
static const TopicRow kTopics[] = {
{"sub", kTopicCommand}, {"pub", kTopicStatusCode},
{"pub", kTopicCamEvent}, {"pub", kTopicCommandReply},
};
for (const auto& t : kTopics) {
const ChannelMessage* last = nullptr;
for (const auto& m : v.recent)
if (topicLeaf(m.topic) == t.leaf) last = &m; // recent is oldest-first
std::string when = last ? formatTimeAgo(now, last->timestamp_ms) : "\xE2\x80\x94";
rows.push_back(hbox({
text(std::string(" ") + t.dir + " ") | dim,
text(t.leaf) | size(WIDTH, EQUAL, 14),
text(when) | dim | size(WIDTH, EQUAL, 10),
// A long payload (a CommandReply) is cut at the edge, never the
// columns before it.
text(last ? last->payload : "") | dim | xflex_shrink,
}));
}
// --- History ---
rows.push_back(separator());
rows.push_back(hbox({text("RECENT MESSAGES") | bold | color(Color::Green),
text(" (oldest first)") | dim}));
if (v.recent.empty()) {
rows.push_back(text("(nothing sent or received yet)") | dim);
} else {
for (const auto& m : v.recent) {
const bool bad = m.delivery != Delivery::Ok;
Element tag = text(m.outbound ? "PUB" : "RX ") |
(m.outbound ? color(Color::Yellow) : color(Color::Cyan));
std::string note = m.delivery == Delivery::Dropped ? " DROPPED"
: m.delivery == Delivery::Failed ? " FAILED"
: "";
rows.push_back(hbox({
text(formatClockMs(m.timestamp_ms) + " ") | dim,
tag,
text(" " + topicLeaf(m.topic)) | size(WIDTH, EQUAL, 16),
text(" " + m.payload + note) | (bad ? color(Color::Red) : color(Color::Default)) |
xflex_shrink,
}));
}
}
return window(overlayTitle("CONNECTIVITY", "n/Esc:close", st, Color::Green),
scrollableBody(vbox(std::move(rows)), st));
}
// "~/..." for the home directory: the log path is long, and the title is short.
std::string homeShort(const std::string& path) {
const char* home = std::getenv("HOME");
if (home && *home && path.rfind(home, 0) == 0) return "~" + path.substr(std::strlen(home));
return path;
}
Element logPanel(const std::vector<LogLine>& lines, const std::string& file, ScrollState& st) {
std::vector<Element> rows;
for (const auto& l : lines) {
Color c = Color::Default;
switch (l.level) {
case LogLevel::Error: c = Color::Red; break;
case LogLevel::Warn: c = Color::Yellow; break;
case LogLevel::Debug:
case LogLevel::Trace: c = Color::GrayDark; break;
default: break;
}
rows.push_back(text(l.text) | color(c));
}
if (rows.empty()) rows.push_back(text("(no log output yet)") | dim);
// Which file this is going to, so it can be found afterwards; and a cue when
// the view is held back from the newest line.
Element title = hbox({text(" LOG ") | bold | color(Color::GrayLight),
text(file.empty() ? "(no log file) " : homeShort(file) + " ") | dim,
st.follow ? text("")
: text("scrollback ") | color(Color::Yellow) | bold});
return window(title, scrollableBody(vbox(std::move(rows)), st));
}
// Compact strip below the log: the running special op (live) + the last
// calibration/diagnostics result (persists so it doesn't scroll away).
Element activityPanel(const ActivityView& a, const std::string& step) {
std::vector<Element> rows;
if (a.active) {
rows.push_back(hbox({
text(" \xE2\x96\xB6 ") | color(Color::Cyan) | bold,
text(a.title + ": ") | bold,
text(a.status) | color(Color::Cyan),
text(step.empty() ? "" : " \xC2\xB7 step " + step) | dim,
}));
for (const auto& l : a.detail) rows.push_back(text(" " + l));
if (a.heading_setup)
rows.push_back(text(" \xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93 aim \xC2\xB7 "
"Tab step \xC2\xB7 Enter mark \xC2\xB7 Space snap \xC2\xB7 f fit "
"\xC2\xB7 Esc cancel") | dim);
} else if (a.has_result) {
rows.push_back(text(" idle") | dim);
}
if (a.has_result) {
rows.push_back(hbox({text("last: ") | dim,
text(a.result_title) | color(toColor(a.result_color)) | bold}));
for (const auto& l : a.result)
rows.push_back(text(" " + l) | color(toColor(a.result_color)));
}
if (!a.prompt.empty()) {
rows.push_back(hbox({text(" " + a.prompt + " ") | bold | color(Color::Black) |
bgcolor(Color::Yellow)}));
}
return window(text(" ACTIVITY ") | bold | color(Color::Cyan), vbox(std::move(rows)));
}
// Help view ('?'): every section on one page, one aligned line per command,
// scrolled with PgUp/PgDn like every other view. The detail lines stay in the
// catalog for ':help <topic>', which keeps this page a quick reference.
Element helpPanel(ScrollState& st) {
const auto& cat = helpCatalog();
size_t width = 0;
for (const auto& sec : cat)
for (const auto& e : sec.entries) width = std::max(width, e.syntax.size());
width = std::min<size_t>(width, 40); // a rare long syntax wraps rather than
// pushing every summary off screen
std::vector<Element> rows;
for (const auto& sec : cat) {
if (!rows.empty()) rows.push_back(text(""));
rows.push_back(hbox({text(sec.title) | bold | color(Color::Cyan),
text(" " + sec.blurb) | dim}));
for (const auto& e : sec.entries)
rows.push_back(hbox({text(" "),
text(e.syntax) | bold | size(WIDTH, EQUAL, static_cast<int>(width)),
text(" "), paragraph(e.summary) | dim | flex}));
}
rows.push_back(text(""));
rows.push_back(text("Type ':help <topic>' for detail and examples, e.g. ':help capture'.") | dim);
return window(overlayTitle("HELP", "?:close PgUp/Dn:scroll", st, Color::Cyan),
scrollableBody(vbox(std::move(rows)), st));
}
// Aligned "label: value" row for the detail view.
Element kvRow(const std::string& k, const std::string& v, Color vc = Color::Default) {
return hbox({text(k) | dim | size(WIDTH, EQUAL, 12), text(v) | color(vc)});
}
std::string hex8(unsigned v) {
char buf[11];
std::snprintf(buf, sizeof(buf), "0x%08X", v);
return buf;
}
std::string get_or(const std::map<std::string, std::string>& m, const std::string& k) {
auto it = m.find(k);
return it == m.end() ? "?" : it->second;
}
// Live per-axis block: everything we get from the ST telemetry line.
Element axisLiveDetail(const AxisView& a) {
Element state = text(std::string(" ") + axisStateLabel(a.state) + " ")
| color(toColor(axisStateColor(a.state))) | bold;
std::vector<Element> rows = {
hbox({text(a.label + " ") | bold, state, filler(),
text(formatDegrees(a.deg) + " -> " + formatDegrees(a.target_deg)) | bold}),
kvRow("xactual", std::to_string(a.xactual)),
kvRow("xenc", std::to_string(a.xenc)),
kvRow("enc err", std::to_string(a.enc_err)), // live tracking error xactual-xenc
kvRow("SG_RESULT", std::to_string(a.sg)),
kvRow("CS_ACTUAL", std::to_string(a.cs)),
kvRow("PWM", std::to_string(a.pwm)),
kvRow("DRV_STATUS", hex8(a.drv_status), Color::Cyan),
hbox({text("flags") | dim | size(WIDTH, EQUAL, 12),
badge("STILL", a.standstill, Color::Cyan),
badge("MOVE", a.moving, Color::Cyan),
badge("STALL", a.deviation, Color::Red),
badge("OT", a.overtemp, Color::Red),
badge("L", a.endstop_l, Color::Cyan),
badge("R", a.endstop_r, Color::Cyan)}),
};
// Homing limits (endstops found at homing), degrees then raw counts.
if (a.has_limits) {
rows.push_back(hbox({
text("homing lim") | dim | size(WIDTH, EQUAL, 12),
text(formatDegrees(a.lim_neg_deg) + " .. " + formatDegrees(a.lim_pos_deg)) | bold,
text(" (" + std::to_string(a.lim_neg) + ".." + std::to_string(a.lim_pos) + ")") | dim,
}));
}
return vbox(std::move(rows));
}
// Decoded register block for one axis from a parsed firmware dump.
Element axisDumpDetail(const DumpAxis& ax) {
auto reg = [&](const char* k) -> Element {
auto it = ax.regs.find(k);
return kvRow(k, it == ax.regs.end() ? "-" : it->second);
};
auto flags = [](const std::vector<std::string>& f) {
std::string s;
for (size_t i = 0; i < f.size(); ++i) s += (i ? " " : "") + f[i];
return s;
};
auto statusRow = [&](const char* name, const std::string& raw,
const std::vector<std::string>& f) {
return hbox({text(name) | dim | size(WIDTH, EQUAL, 12),
text(raw + " ") | color(Color::Cyan),
text("[" + flags(f) + "]") | dim});
};
return vbox({
hbox({text(std::string(1, ax.axis) + " ") | bold,
text(ax.state_name) | color(Color::Green) | bold,
text(" enabled=" + std::to_string(ax.enabled ? 1 : 0)) | dim,
text(" enc=" + std::to_string(ax.has_encoder ? 1 : 0)) | dim}),
kvRow("limits", "[" + std::to_string(ax.lim_neg) + ".." + std::to_string(ax.lim_pos) + "]"),
kvRow("hold_tgt", std::to_string(ax.hold_target)),
reg("GCONF"), reg("CHOPCONF"), reg("XACTUAL"), reg("X_ENC"),
reg("XTARGET"), reg("VACTUAL"),
statusRow("DRV_STATUS", get_or(ax.regs, "DRV_STATUS"), ax.drv_flags),
hbox({text("") | size(WIDTH, EQUAL, 12),
text("CS_ACTUAL=" + std::to_string(ax.cs_actual) +
" SG_RESULT=" + std::to_string(ax.sg_result)) | dim}),
statusRow("GSTAT", get_or(ax.regs, "GSTAT"), ax.gstat_flags),
statusRow("RAMP_STAT", get_or(ax.regs, "RAMP_STAT"), ax.ramp_flags),
});
}
// One axis's full panel: live telemetry on top, then its decoded firmware
// registers. Rendered per axis so the two axes sit side by side and every row
// (incl. RAMP_STAT, the last one) is visible without scrolling.
Element axisColumn(const std::string& title, const AxisView& live, const DumpData& d,
char letter, bool calib_has, const CalibAxisView& cal,
const AxisMapView& live_map, const DiagAxisView* diag, ScrollState& st) {
auto fmt = [](const char* f, double v) {
char b[32];
std::snprintf(b, sizeof(b), f, v);
return std::string(b);
};
std::vector<Element> col;
col.push_back(axisLiveDetail(live));
col.push_back(separator());
const DumpAxis* ax = nullptr;
if (d.valid)
for (const auto& a : d.axes)
if (a.axis == letter) ax = &a;
if (ax)
col.push_back(axisDumpDetail(*ax));
else
col.push_back(text(d.valid ? "(axis absent from dump)"
: "(awaiting dump - press 'd')") | dim);
// Encoder-tracking health: disturbances the firmware hold corrector counted
// and erased (from the firmware dump), plus the last DIAG tracking errors.
col.push_back(separator());
col.push_back(text("TRACKING") | bold | color(Color::Yellow));
if (live.has_track) {
col.push_back(kvRow("corrections", std::to_string(live.hold_corrections)));
col.push_back(kvRow("peak dev", std::to_string(live.hold_peak_dev)));
col.push_back(kvRow("cumul slip", std::to_string(live.hold_cumulative_dev)));
} else {
col.push_back(text("(awaiting dump - press 'd')") | dim);
}
if (diag && diag->has) {
if (diag->err_peak < 0) {
col.push_back(kvRow("diag", "no encoder"));
} else {
col.push_back(kvRow("diag peak", std::to_string(diag->err_peak)));
col.push_back(kvRow("diag rms", std::to_string(diag->err_rms)));
col.push_back(kvRow("diag still", std::to_string(diag->err_still)));
col.push_back(kvRow("diag", diag->pass ? "PASS" : "FAIL",
diag->pass ? Color::Green : Color::Red));
}
} else {
col.push_back(kvRow("diag", "run 'diag'", Color::GrayDark));
}
// Last calibration result for this axis.
col.push_back(separator());
col.push_back(text("CALIBRATION") | bold | color(Color::Magenta));
// The values actually in use, always. They are what every bearing is computed
// from, so showing nothing here because no fit happened today told the
// operator the axis was uncalibrated when it was not.
col.push_back(kvRow("cnt/deg", fmt("%.3f", live_map.counts_per_deg)));
col.push_back(kvRow("deg@ctr", fmt("%.3f", live_map.deg_at_center)));
if (calib_has && cal.ok) {
// A fit ran this session: add its quality, and flag if the live map has
// since moved away from it (a homing derivation, or a manual offset).
col.push_back(kvRow("R2", fmt("%.4f", cal.r2), cal.r2 >= 0.99 ? Color::Green : Color::Yellow));
col.push_back(kvRow("points", std::to_string(cal.n)));
col.push_back(text("from a fit this session") | dim);
} else if (calib_has) {
col.push_back(text("last fit this session FAILED") | color(Color::Yellow));
} else {
col.push_back(text("from config, no fit this session") | dim);
}
// Each axis keeps its own frame so the YAW/PITCH titles stay put; framing the
// whole hbox instead would scroll the titles away with the content.
return panel(title, Color::Cyan, scrollableBody(vbox(std::move(col)), st));
}
// Full-screen gimbal view (toggled with 'g'): one column per axis, each with
// live telemetry above its decoded firmware register dump + last calibration.
Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const CalibResultView& calib,
const DiagResultView& diag, std::array<ScrollState, 2>& st) {
DumpData d = parseDump(dump.text);
auto diagFor = [&](char axis) -> const DiagAxisView* {
for (const auto& a : diag.axes)
if (a.axis == axis) return &a;
return nullptr;
};
// The firmware's own name for the reset cause wins. reset_flags is an AVR
// MCUSR bit decode, and on the RP2040 that field is getResetReason() - a
// plain enum - so decoding it as bits showed every post-flash soft reset here
// as "PORF (power-on) EXTRF (external)". Only the board knows its encoding.
std::string reset = d.reset_text;
if (reset.empty()) {
for (size_t i = 0; i < d.reset_flags.size(); ++i)
reset += (i ? " " : "") + d.reset_flags[i];
// Firmware predating `reset=`: the decode is an assumption, so say so
// rather than presenting it as fact.
if (!reset.empty()) reset += "?";
}
std::string calib_note = "calibration from config";
if (calib.has) {
long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
calib_note = "calibrated " + formatTimeAgo(now, calib.ts_ms);
}
Element header = hbox({
(g.present ? text(" link up ") | color(Color::Green)
: text(" link down ") | color(Color::Red) | bold),
text(" " + calib_note) | color(calib.has ? Color::Magenta : Color::GrayDark),
filler(),
text(d.valid ? ("build " + d.build + " up " + std::to_string(d.uptime_ms) +
"ms reset:" + reset)
: std::string("no dump yet")) | dim,
});
std::vector<Element> cols;
cols.push_back(axisColumn("YAW", g.yaw, d, 'Y', calib.has, calib.yaw, calib.live_yaw,
diagFor('Y'), st[0]));
if (g.pitch_present)
cols.push_back(axisColumn("PITCH", g.pitch, d, 'P', calib.has, calib.pitch,
calib.live_pitch, diagFor('P'), st[1]));
return window(overlayTitle("GIMBAL", "g/Esc:close d:refresh dump", st[0]),
vbox({header, separator(), hbox(std::move(cols)) | flex}));
}
// Full-screen Sensors view (toggled with 's'): every MTi channel with units,
// then the ambient sensor. The two devices get their own headed sections, and
// each temperature is labelled with its provenance ("internal" vs "ambient"),
// so the reading the main window shows is never confused with the MTi's.
Element sensorsDetailPanel(const ImuView& v, const EnvView& e, const HostView& h,
ScrollState& st) {
// Fixed-width, right-aligned to 2 decimals. The constant width keeps the sign
// column and decimal point from jumping as values cross zero or change digit
// count, so the readout stays steady instead of flickering. The width is kept
// just wide enough for the field's range so the number sits close to its
// x/y/z label: 6 for the vectors (accel/gyro/mag stay well under ±100), 7 for
// orientation (so a 3-digit "-180.00" still fits without widening).
auto f2 = [](float x, int w) {
char b[24];
std::snprintf(b, sizeof(b), "%*.2f", w, x);
return std::string(b);
};
// A dim " │ " divider between value columns.
auto vsep = [] { return text(" \xE2\x94\x82 ") | dim; };
// One "LABEL (unit) x:.. │ y:.. │ z:.." row for a 3-vector.
auto vecRow = [&](const std::string& label, const char* unit, const float xyz[3],
Color c = Color::Default) {
return hbox({
text(label) | dim | size(WIDTH, EQUAL, 14),
text(std::string(unit)) | dim | size(WIDTH, EQUAL, 9),
text("x:" + f2(xyz[0], 6)) | color(c) | size(WIDTH, EQUAL, 8),
vsep(),
text("y:" + f2(xyz[1], 6)) | color(c) | size(WIDTH, EQUAL, 8),
vsep(),
text("z:" + f2(xyz[2], 6)) | color(c) | size(WIDTH, EQUAL, 8),
});
};
const float ori[3] = {v.roll_deg, v.pitch_deg, v.yaw_deg};
std::vector<Element> body;
body.push_back(text("MTi \xE2\x80\x94 orientation & device-internal temperature") | bold
| color(Color::Magenta));
if (!v.present) {
body.push_back(text("(no IMU data)") | color(Color::Red) | bold);
body.push_back(text("declare [Hardware] imu and set [IMU] device, or --mock-imu") | dim);
} else {
body.push_back(hbox({
text("ORIENTATION") | dim | size(WIDTH, EQUAL, 14),
text("deg") | dim | size(WIDTH, EQUAL, 9),
text("roll:" + f2(ori[0], 6)) | bold | size(WIDTH, EQUAL, 11),
vsep(),
text("pitch:" + f2(ori[1], 6)) | bold | size(WIDTH, EQUAL, 12),
vsep(),
text("yaw:" + f2(ori[2], 6)) | bold | size(WIDTH, EQUAL, 10),
}));
body.push_back(separator());
body.push_back(vecRow("ACCEL", "m/s2", v.acc, Color::Cyan));
body.push_back(vecRow("RATE OF TURN", "rad/s", v.gyr, Color::Cyan));
body.push_back(vecRow("MAG FIELD", "a.u.", v.mag, Color::Cyan));
body.push_back(separator());
body.push_back(hbox({
text("TEMP (internal)") | dim | size(WIDTH, EQUAL, 16),
text(f2(v.temp_c, 6) + " \xC2\xB0""C") | bold | size(WIDTH, EQUAL, 18),
text("sample #" + std::to_string(v.sample_counter)) | dim,
}));
}
// AMBIENT section: the SHT41 (or whatever IEnvSensor backs it — the name comes
// from the driver, so a mock announces itself). This is the temperature the
// main window shows. It sits directly under the MTi's live rows and ABOVE the
// static IMU CONFIG block: on an 80x24 terminal the body overflows once the
// MTi reports its configuration, and the live readings from both sensors are
// what must stay on screen — the never-changing device config is what gives way
// (and is still reachable by scrolling).
{
auto envRow = [](const std::string& k, Element val) {
return hbox({text(k) | dim | size(WIDTH, EQUAL, 16), std::move(val)});
};
auto f1 = [](float x) {
char b[24];
std::snprintf(b, sizeof(b), "%.1f", x);
return std::string(b);
};
body.push_back(separator());
body.push_back(text((e.name.empty() ? std::string("SHT41") : e.name) +
(e.on_mcu ? " \xE2\x80\x94 ambient (on the motor MCU)"
: " \xE2\x80\x94 ambient (I2C)")) |
bold | color(Color::Magenta));
if (!e.enabled) {
body.push_back(text("(env sensor disabled)") | dim);
body.push_back(
text("declare [Hardware] env and set [Env] i2c_device, or --mock-env")
| dim);
} else if (!e.present) {
body.push_back(text("(no reading)") | color(Color::Red) | bold);
// Kept short enough to fit an 80-column terminal without truncating.
body.push_back(text("no sample within 2x the " + std::to_string(e.period_ms) +
" ms poll period \xE2\x80\x94 check wiring/bus") | dim);
} else {
long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
body.push_back(envRow("TEMP (ambient)",
text(f2(e.temp_c, 6) + " \xC2\xB0""C") | bold));
body.push_back(envRow("HUMIDITY", text(f1(e.humidity_pct) + " %RH") | bold));
body.push_back(envRow("Last sample", text(formatTimeAgo(now, e.timestamp_ms)) | dim));
}
// The configured bus is meaningless for the mock, so only show it for real
// hardware — where it is exactly what you need to debug a missing reading.
if (e.fan) {
std::string v = std::to_string(e.fan_duty) + "/255";
v += e.fan_tach ? ", " + std::to_string(e.fan_rpm) + " rpm" : ", no tach wired";
body.push_back(envRow("FAN", e.fan_stalled
? text(v + " NOT TURNING") | color(Color::Red) | bold
: text(v) | bold));
body.push_back(text("off to 25 \xC2\xB0""C, full from 37.75 \xC2\xB0""C, full "
"while the sensor is unread") | dim);
}
if (e.on_mcu) {
body.push_back(envRow("Bus", text("read by the motor MCU, every " +
std::to_string(e.period_ms) + " ms") | dim));
} else if (e.enabled && !e.mock) {
char addr[16];
std::snprintf(addr, sizeof(addr), "0x%02X", e.i2c_addr);
body.push_back(envRow("Bus", text(e.i2c_device + " @ " + addr + ", every " +
std::to_string(e.period_ms) + " ms") | dim));
}
}
// LATTEPANDA CPU section: the computer's own heat and load, and how the CPU
// guard (ThermalGuard) is pacing the scan for it. Live like the two above,
// so it sits above the static IMU CONFIG block too.
{
auto row = [](const std::string& k, Element val) {
return hbox({text(k) | dim | size(WIDTH, EQUAL, 16), std::move(val)});
};
auto fx = [](double x, int d) {
char b[32];
std::snprintf(b, sizeof(b), "%.*f", d, x);
return std::string(b);
};
body.push_back(separator());
body.push_back(text("LattePanda \xE2\x80\x94 CPU") | bold | color(Color::Magenta));
if (!h.present) {
body.push_back(text("(no reading yet)") | dim);
} else {
const bool hot = h.temp_ok && h.cpu_c >= h.slow_c;
Element temp = h.temp_ok ? text(fx(h.cpu_c, 1) + " \xC2\xB0""C") | bold
: text("unknown") | dim;
if (hot) temp = temp | color(h.cpu_c >= h.pause_c ? Color::Red : Color::Yellow);
body.push_back(row("CPU temp", hbox({temp, text(" " + h.zone) | dim})));
if (h.clock_ok) {
std::string cores;
for (double m : h.core_mhz) cores += (cores.empty() ? "" : " ") + fx(m / 1000.0, 1);
std::string cap;
if (h.cap_mhz > 0.0 && h.hw_max_mhz > 0.0 && h.cap_mhz < h.hw_max_mhz - 1.0)
cap = ", capped at " + fx(h.cap_mhz / 1000.0, 1);
body.push_back(row("Clock", hbox({text(fx(h.mean_mhz / 1000.0, 2) + " GHz") | bold,
text(" of " + fx(h.hw_max_mhz / 1000.0, 1) +
cap + " (cores " + cores + ")") | dim})));
} else {
body.push_back(row("Clock", text("unknown") | dim));
}
body.push_back(row("Load",
hbox({text(h.busy_pct >= 0.0 ? fx(h.busy_pct, 0) + " % busy"
: std::string("-- % busy")) | bold,
text(" load " + fx(h.load1, 2) + " / " + fx(h.load5, 2) +
" / " + fx(h.load15, 2) + " on " +
std::to_string(h.cpus) + " cores") | dim})));
std::string thr = h.throttling ? "yes, now (" + h.throttle_cause + ")" : "no";
std::string thr_detail = h.counters_ok
? " " + std::to_string(h.throttle_last_min) + " in the last minute, " +
std::to_string(h.throttle_total) + " since start"
: " (the kernel does not count it here; judged from the clock)";
body.push_back(row("Throttled",
hbox({h.throttling ? text(thr) | color(Color::Red) | bold
: text(thr) | bold,
text(thr_detail) | dim})));
if (h.save_count > 0)
body.push_back(row("Saving", hbox({text(fx(h.save_mean_ms, 0) + " ms") | bold,
text(" per picture, longest " +
fx(h.save_max_ms, 0) + " ms, " +
std::to_string(h.save_count) +
" in the last minute") | dim})));
Element st = text(h.state);
if (!h.guard_on) st = text("off (full pace whatever the heat)") | color(Color::Yellow) | bold;
else if (h.state == "paused") st = st | color(Color::Red) | bold;
else if (h.state == "slowed") st = st | color(Color::Yellow) | bold;
else st = st | color(Color::Green);
std::string pace = " one picture every " + fx(h.interval_s, 1) + " s";
if (h.state == "paused") pace = " for " + std::to_string(h.paused_s) + " s";
body.push_back(row("Scan pace", hbox({st, text(pace) | dim})));
if (!h.reason.empty()) body.push_back(row("", text(h.reason) | dim));
if (h.held_s > 0)
body.push_back(row("", text("waiting " + std::to_string(h.held_s) +
" s for saving to catch up") | color(Color::Yellow)));
body.push_back(text("slows at " + fx(h.slow_c, 0) + " \xC2\xB0""C, pauses at " +
fx(h.pause_c, 0) + ", resumes at " + fx(h.resume_c, 0) +
" ('thermal' to see, 'thermal off|on|resume')") | dim);
}
}
// IMU CONFIG section (device configuration read back at startup). Shown
// whenever it is known, even if the live stream is offline.
const auto& c = v.config;
// A configured mount is worth showing even when the device never answered the
// configuration queries - it is the one line here that comes from config.ini
// and is what explains why the readings above are not the sensor's own.
if (c.present || !c.mount.empty() || !c.problems.empty()) {
auto cfgRow = [](const std::string& k, const std::string& val,
Color vc = Color::Default) {
return hbox({text(k) | dim | size(WIDTH, EQUAL, 16),
text(val) | color(vc)});
};
body.push_back(separator());
body.push_back(text("IMU CONFIG") | bold | color(Color::Magenta));
// Before anything else: the device is not doing what it was told to do, so
// every orientation number above is suspect. Red rather than the Mount
// row's yellow, which is a normal state and would blunt this one.
for (const auto& p : c.problems)
body.push_back(hbox({text("MISCONFIGURED") | color(Color::Red) | bold
| size(WIDTH, EQUAL, 16),
paragraph(p) | color(Color::Red)}));
// Mounting rotation first: it changes the meaning of every number above,
// and it is the one row here the operator sets rather than the device.
// (The earth-frame correction is deliberately not shown: it is a fixed
// property of the device, not something the operator sets or acts on. It
// is stated once on the startup log line.)
if (c.mount.empty())
body.push_back(hbox({text("Mount (cfg)") | dim | size(WIDTH, EQUAL, 16),
text("none") | dim,
text(" \xE2\x80\x94 readings are as the sensor reports them")
| dim}));
else
body.push_back(hbox({text("Mount (cfg)") | dim | size(WIDTH, EQUAL, 16),
text(c.mount) | color(Color::Yellow) | bold,
text(" deg, rotations from IMU frame to gimbal frame")
| dim}));
if (!c.present) {
// Everything below this point is device-reported and unavailable.
body.push_back(hbox({text("") | size(WIDTH, EQUAL, 16),
text("(device configuration not reported)") | dim}));
}
if (c.present) {
body.push_back(cfgRow("Product", c.product_code));
body.push_back(cfgRow("Firmware", c.firmware));
body.push_back(cfgRow("Device ID", c.device_id));
body.push_back(cfgRow("Output mode", c.output_mode));
body.push_back(cfgRow("Output fmt", c.output_settings));
body.push_back(cfgRow("Calib channels", c.channels));
body.push_back(cfgRow("Sample rate", c.sample_rate));
// XKF profile list: the active one is marked "●" and highlighted; the
// rest are dim "○". (Numeric profile IDs are intentionally hidden.)
body.push_back(text("Xsens Kalman Filter (XKF) profile") | dim);
if (c.xkf_profiles.empty()) {
body.push_back(hbox({text(" "), text("(not reported by device)") | dim}));
} else {
for (const auto& p : c.xkf_profiles) {
if (p.selected)
body.push_back(hbox({text(" \xE2\x97\x8F ") | color(Color::Yellow),
text(p.name) | color(Color::Yellow) | bold,
text(" (selected)") | dim}));
else
body.push_back(hbox({text(" \xE2\x97\x8B ") | dim,
text(p.name) | dim}));
}
}
}
}
Element imu_status = v.present ? (text(" MTi live ") | color(Color::Green) | bold)
: (text(" MTi offline ") | color(Color::Red) | bold);
const std::string env_name = e.name.empty() ? "SHT41" : e.name;
Element env_status = !e.enabled
? (text(" " + env_name + " disabled ") | dim)
: (e.present
? (text(" " + env_name + " live ") | color(Color::Green) | bold)
: (text(" " + env_name + " no reading ") | color(Color::Red) |
bold));
// Scrolled so a long body (MTi channels + config + ambient) stays reachable on
// a short terminal instead of being silently cut off.
return window(overlayTitle("SENSORS", "s/Esc:close", st, Color::Magenta),
vbox({hbox({imu_status, text(" "), env_status, filler()}), separator(),
scrollableBody(vbox(std::move(body)), st) | flex}));
}
} // namespace
TuiUi::TuiUi() = default;
TuiUi::~TuiUi() { stop(); }
void TuiUi::start(SnapshotFn snapshot, CommandSink sink) {
snapshot_ = std::move(snapshot);
sink_ = std::move(sink);
running_ = true;
// Divert log output into the on-screen pane so it never corrupts the screen.
Logger::setSink([this](LogLevel lvl, const std::string& line) { pushLog(lvl, line); });
ui_thread_ = std::thread(&TuiUi::uiLoop, this);
refresh_thread_ = std::thread(&TuiUi::refreshLoop, this);
}
void TuiUi::stop() {
if (!running_.exchange(false)) return;
if (auto* s = screen_.load()) s->Exit(); // breaks the FTXUI Loop
if (ui_thread_.joinable()) ui_thread_.join();
if (refresh_thread_.joinable()) refresh_thread_.join();
Logger::setSink({}); // restore the default stdout/stderr writer
}
void TuiUi::pushLog(LogLevel level, const std::string& line) {
std::lock_guard<std::mutex> lock(log_mutex_);
log_.push_back({level, line});
while (log_.size() > kLogCap) {
log_.pop_front();
++log_dropped_;
}
}
// Wall-clock time the frame was frozen at, "HH:MM:SS". Captured once, because
// the badge must not need a repaint to stay truthful - repainting would unfreeze
// the very data the pause is holding still.
std::string TuiUi::nowHms() {
const auto t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now());
std::tm tm{};
localtime_r(&t, &tm);
char buf[16];
std::strftime(buf, sizeof(buf), "%H:%M:%S", &tm);
return buf;
}
void TuiUi::refreshLoop() {
using namespace std::chrono_literals;
auto last_dump = std::chrono::steady_clock::now();
while (running_) {
std::this_thread::sleep_for(100ms);
if (!running_) break;
// Frozen for text selection: do NOT repaint. Re-rendering would pull the
// live snapshot and the numbers would keep moving under the cursor,
// which is precisely what the freeze exists to prevent. The badge
// carries the wall-clock time of the freeze instead, so the frame says
// how old it is without having to be redrawn to say it.
if (paused_) continue;
// While the gimbal overlay is open, re-pull a firmware dump every ~3s so
// the encoder-tracking counters stay current without pressing 'd'.
if (gimbal_overlay_open_.load() && sink_ &&
std::chrono::steady_clock::now() - last_dump > 3s) {
sink_("gimbal dump");
last_dump = std::chrono::steady_clock::now();
}
if (auto* s = screen_.load()) s->PostEvent(Event::Custom); // force a redraw
}
}
void TuiUi::uiLoop() {
std::string cmd_buffer;
int cmd_cursor = 0;
bool command_mode = false;
Overlay overlay = Overlay::None; // which takeover panel owns the main area
bool gimbal_dump_requested = false; // auto-pull a dump the first time
bool calib_prompt = false; // a yes/no calib-save question is showing
// Mirrored out of the snapshot each render: CatchEvent runs on the same
// thread but has no access to the renderer's local snapshot copy.
bool manual_mode = true;
bool capture_on = false; // continuous capture, so r knows which way to toggle
bool heading_setup = false;
double yaw_deg = 0.0, pitch_deg = 0.0; // where the gimbal points, for 'p'
bool pitch_present = false;
long yaw_travel = 0, pitch_travel = 0; // homed travel, for the aiming steps
bool exit_prompt = false; // 'q' asked; awaiting quit/background/cancel
JogPhase jog_phase = JogPhase::Fine; // heading setup: step size of the arrows
// Paused: the whole frame is held, log included, so what the operator pages
// through or selects does not move under them.
UiSnapshot frozen;
bool have_frozen = false;
// Per-pane scroll positions. UI-thread-only view state (the renderer lambda
// and CatchEvent below both run on this thread), so unlike screen_/paused_
// these need no synchronisation and stay locals rather than TuiUi members.
ScrollState cam_scroll, sensors_scroll, help_scroll, log_scroll, conn_scroll;
std::array<ScrollState, 2> gimbal_scroll{}; // YAW, PITCH
log_scroll.follow = true; // the log tracks the tail
size_t log_dropped_at_freeze = 0; // buffer evictions when scrollback was entered
size_t log_dropped_seen = 0;
InputOption input_opt;
input_opt.placeholder = "type a command, Enter to run, Esc to cancel";
input_opt.cursor_position = &cmd_cursor;
auto input = Input(&cmd_buffer, input_opt);
auto renderer = Renderer(input, [&] {
UiSnapshot s;
size_t log_dropped_now = 0;
if (paused_ && have_frozen) {
s = frozen;
log_dropped_now = log_dropped_seen;
} else {
s = snapshot_ ? snapshot_() : UiSnapshot{};
std::lock_guard<std::mutex> lock(log_mutex_);
s.log.assign(log_.begin(), log_.end());
log_dropped_now = log_dropped_;
}
if (paused_ && !have_frozen) { frozen = s; have_frozen = true; }
if (!paused_) have_frozen = false;
log_dropped_seen = log_dropped_now;
// New lines show at once, unless paused (followNew). While held back,
// evicting from the front of the ring buffer would slide every line up
// under the viewport; absorb the shift, the clamp handles the top.
followNew(log_scroll, log_dropped_now + s.log.size(), paused_);
if (!log_scroll.follow && log_dropped_now != log_dropped_at_freeze) {
log_scroll.top -= static_cast<int>(log_dropped_now - log_dropped_at_freeze);
log_dropped_at_freeze = log_dropped_now;
}
const ChannelStats& cs = s.conn.stats;
followNew(conn_scroll,
static_cast<unsigned long long>(cs.published_ok + cs.published_dropped +
cs.published_failed + cs.received),
paused_);
const auto dump_stamp = std::hash<std::string>{}(s.dump.text);
for (auto& g : gimbal_scroll) followNew(g, dump_stamp, paused_);
calib_prompt = !s.activity.prompt.empty();
manual_mode = s.capture.manual;
capture_on = s.capture.continuous;
heading_setup = s.activity.heading_setup;
yaw_deg = s.gimbal.yaw.deg;
pitch_deg = s.gimbal.pitch.deg;
pitch_present = s.gimbal.pitch_present;
yaw_travel = s.gimbal.yaw.has_limits ? std::labs(s.gimbal.yaw.lim_pos - s.gimbal.yaw.lim_neg) : 0;
pitch_travel =
s.gimbal.pitch.has_limits ? std::labs(s.gimbal.pitch.lim_pos - s.gimbal.pitch.lim_neg) : 0;
gimbal_overlay_open_.store(overlay == Overlay::Gimbal); // refreshLoop polls a dump while open
std::string mode = s.header.live ? "LIVE" : "MOCK";
Element header = hbox({
text(" FIREWATCH TOWER ") | bold | inverted,
text(" " + s.header.tower + " ") | bold,
text("build " + s.header.build) | dim,
filler(),
// Says WHEN it froze, not just that it is frozen. Without the time a
// held frame is indistinguishable from a hung program - the numbers
// simply stop - which during a multi-minute home reads as a crash.
(paused_ ? text(" PAUSED @" + paused_at_hms_ + " ")
| color(Color::Black) | bgcolor(Color::Yellow) | bold
: text("")),
text(" " + upperStr(s.header.mode) + " ") | inverted | 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,
});
// Pin the LEFT panel of each row to exactly half the terminal and let the
// right one flex into the remainder, so both rows split at the same
// column. Two `| flex` panels do not: FTXUI grows each from its own
// min_size (box_helper.cpp), so differing content moved the seam.
// size(WIDTH, EQUAL, n) also zeroes flex_grow (size.cpp), which is what
// makes the left panel hold its width.
ScreenInteractive* sc = screen_.load();
const int half = sc ? sc->dimx() / 2 : 0;
auto halve = [half](Element e) {
return half > 0 ? std::move(e) | size(WIDTH, EQUAL, half) : std::move(e);
};
Element top = hbox({halve(gimbalPanel(s.gimbal)), sensorsPanel(s.sensors)});
Element middle = hbox({halve(cameraPanel(s.capture)), connPanel(s.conn)});
Element bottom = command_mode
? hbox({text(" : ") | inverted, input->Render() | flex}) | border
: exit_prompt ? exitPrompt(backgroundAvailable(std::getenv("TMUX")))
: keyBar(overlay, s.capture.manual, heading_setup,
s.gimbal.pitch_present);
// An open overlay takes over the whole body (the dashboard panels are
// hidden) so it has the full height — otherwise tall content like the
// gimbal register dump overflows the small log-sized area and clips.
switch (overlay) {
case Overlay::Help:
return vbox({header, separator(), helpPanel(help_scroll) | flex, bottom});
case Overlay::Gimbal:
return vbox({header, separator(),
gimbalDetailPanel(s.gimbal, s.dump, s.calib, s.diag, gimbal_scroll) |
flex,
bottom});
case Overlay::Sensors:
return vbox({header, separator(),
sensorsDetailPanel(s.imu, s.env, s.host, sensors_scroll) | flex, bottom});
case Overlay::Cameras:
return vbox({header, separator(), cameraDetailPanel(s.capture, cam_scroll) | flex, bottom});
case Overlay::Conn:
return vbox({header, separator(), connDetailPanel(s.conn, conn_scroll) | 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.
std::vector<Element> col = {header, separator(), top, middle,
logPanel(s.log, s.log_file, log_scroll) | flex};
if (s.activity.active || s.activity.has_result || !s.activity.prompt.empty()) {
const std::string step =
s.activity.heading_setup
? std::string(jogPhaseName(jog_phase)) + " " +
jogStepLabel(jog_phase, yaw_travel, s.gimbal.yaw.counts_per_deg)
: "";
col.push_back(activityPanel(s.activity, step));
}
col.push_back(bottom);
return vbox(std::move(col));
}
}
});
// PgUp/PgDn are the only scroll keys: the arrows belong to the gimbal. Paging
// up from the bottom leaves "follow" where the view stood; paging back down
// to the bottom resumes it.
auto scrollKeys = [](ScrollState& st, const Event& e) {
if (e != Event::PageUp && e != Event::PageDown) return false;
const int ch = st.contentH(), vh = st.viewH();
if (st.follow) {
st.follow = false;
st.top = scrollMax(ch, vh);
}
st.top = scrollPage(st.top, e == Event::PageDown ? 1 : -1, ch, vh);
if (e == Event::PageDown && st.top >= scrollMax(ch, vh)) st.follow = true;
return true;
};
auto root = CatchEvent(renderer, [&](Event e) {
if (command_mode) {
if (e == Event::Return) {
if (!cmd_buffer.empty() && sink_) sink_(cmd_buffer);
cmd_buffer.clear();
cmd_cursor = 0;
command_mode = false;
return true;
}
if (e == Event::Escape) {
cmd_buffer.clear();
cmd_cursor = 0;
command_mode = false;
return true;
}
return false; // let the Input edit the buffer
}
// The exit question owns the keyboard while it is up: every key is
// consumed, so a stray press cannot reach the dashboard behind it.
if (exit_prompt) {
if (e == Event::Escape) { exit_prompt = false; return true; }
if (e == Event::Return) { exit_prompt = false; if (sink_) sink_("exit"); return true; }
if (e.is_character()) {
const std::string& k = e.character();
if (k == "q" || k == "Q") {
exit_prompt = false;
if (sink_) sink_("exit");
} else if ((k == "b" || k == "B") && backgroundAvailable(std::getenv("TMUX"))) {
exit_prompt = false;
// Detach the client viewing this pane; the program keeps
// running and drawing into the pty, exactly as Ctrl-b d does.
// A failure must never read as "quit", so on error we say so
// and stay put.
if (std::system("tmux detach-client") != 0)
LOG_WARN << "could not detach from tmux; still running "
"(detach with Ctrl-b d, or press q to quit)";
}
}
return true; // swallow everything else
}
// Heading setup runs in the activity strip and takes the keys it needs:
// arrows aim in the chosen step, Enter starts a mark on the command line,
// f fits, Esc cancels. Everything else still works (views, help, quit).
if (heading_setup && sink_) {
auto jog = [&](const char* axis, long travel, long sign) {
const long step = jogStepCounts(jog_phase, travel) * sign;
sink_(std::string("gimbal jog ") + axis + " " + std::to_string(step));
};
if (e == Event::ArrowLeft) { jog("yaw", yaw_travel, -1); return true; }
if (e == Event::ArrowRight) { jog("yaw", yaw_travel, +1); return true; }
// Pitch too: the landmark has to be in frame vertically before its
// bearing means anything. A yaw-only tower has nothing to send.
if (e == Event::ArrowUp) { if (pitch_present) jog("pitch", pitch_travel, +1); return true; }
if (e == Event::ArrowDown) { if (pitch_present) jog("pitch", pitch_travel, -1); return true; }
if (e == Event::Tab) {
jog_phase = jog_phase == JogPhase::Coarse ? JogPhase::Fine
: jog_phase == JogPhase::Fine ? JogPhase::VeryFine
: JogPhase::Coarse;
return true;
}
if (e == Event::Return) {
cmd_buffer = "gimbal heading mark ";
cmd_cursor = static_cast<int>(cmd_buffer.size());
command_mode = true;
return true;
}
if (e == Event::Escape && overlay == Overlay::None) {
sink_("gimbal heading cancel");
return true;
}
if (e == Event::Character("f")) { sink_("gimbal heading fit"); return true; }
}
// Scroll the view that owns the body, or the log. PgUp/PgDn only.
switch (overlay) {
case Overlay::None: if (scrollKeys(log_scroll, e)) {
std::lock_guard<std::mutex> lock(log_mutex_);
log_dropped_at_freeze = log_dropped_;
return true;
}
break;
case Overlay::Cameras: if (scrollKeys(cam_scroll, e)) return true; break;
case Overlay::Conn: if (scrollKeys(conn_scroll, e)) return true; break;
case Overlay::Sensors: if (scrollKeys(sensors_scroll, e)) return true; break;
case Overlay::Help: if (scrollKeys(help_scroll, e)) return true; break;
case Overlay::Gimbal: {
// Both axis columns move together: they are read side by side,
// and each clamps against its own measured height.
const bool a = scrollKeys(gimbal_scroll[0], e);
const bool b = scrollKeys(gimbal_scroll[1], e);
if (a || b) return true;
break;
}
}
if (overlay != Overlay::None && e == Event::Escape) { overlay = Overlay::None; return true; }
// Esc with no overlay open cancels a running procedure (test/calib/homing).
if (overlay == Overlay::None && e == Event::Escape && sink_) {
if (snapshot_ && snapshot_().activity.cancelable) { sink_("cancel"); return true; }
}
// The arrows nudge the gimbal, from any view: left/right = yaw -/+5%,
// up/down = pitch -/+10% of travel. Only in manual mode: in auto and pan
// the app refuses these anyway, and a silent no-op beats a warning per
// keypress from someone leaning on an arrow. Likewise up/down on a
// yaw-only tower.
if (manual_mode && sink_) {
if (e == Event::ArrowLeft) { sink_("gimbal nudge yaw -5"); return true; }
if (e == Event::ArrowRight) { sink_("gimbal nudge yaw 5"); return true; }
if (e == Event::ArrowUp) { if (pitch_present) sink_("gimbal nudge pitch -10"); return true; }
if (e == Event::ArrowDown) { if (pitch_present) sink_("gimbal nudge pitch 10"); return true; }
}
if (!e.is_character()) return false;
const std::string& c = e.character();
// Answer the activity-strip "save calibration?" yes/no prompt.
if (calib_prompt && overlay == Overlay::None && sink_) {
if (c == "y" || c == "Y") { sink_("calib save"); return true; }
if (c == "n" || c == "N") { sink_("calib discard"); return true; }
}
auto toggle = [&](Overlay o, ScrollState* st) {
overlay = (overlay == o) ? Overlay::None : o;
if (overlay == o && st) *st = {};
};
if (c == "?") { toggle(Overlay::Help, &help_scroll); return true; }
if (c == "g") {
if (overlay == Overlay::Gimbal) {
overlay = Overlay::None;
} else {
overlay = Overlay::Gimbal;
gimbal_scroll = {};
if (!gimbal_dump_requested) { // auto-pull a dump the first time
if (sink_) sink_("gimbal dump");
gimbal_dump_requested = true;
}
}
return true;
}
if (overlay == Overlay::Gimbal && c == "d") { // manual dump refresh
if (sink_) sink_("gimbal dump");
return true;
}
if (c == "s") { toggle(Overlay::Sensors, &sensors_scroll); return true; }
if (c == "c") { toggle(Overlay::Cameras, &cam_scroll); return true; }
if (c == "n") { toggle(Overlay::Conn, &conn_scroll); return true; }
if (c == "f") { // freeze/unfreeze: to select text, or to read back
paused_ = !paused_;
if (paused_) paused_at_hms_ = nowHms();
return true; // this keypress redraws once so the indicator updates
}
if (c == "q") { exit_prompt = true; return true; }
if (c == "a") { if (sink_) sink_("mode auto"); return true; }
if (c == "m") { if (sink_) sink_("mode manual"); return true; }
// Pan 30 degrees around where the gimbal points now.
if (c == "p") {
if (sink_) {
std::string cmd = "mode pan " + num(yaw_deg, 1) + " 30";
if (pitch_present) cmd += " " + num(pitch_deg, 1);
sink_(cmd);
}
return true;
}
if (c == "h") { if (sink_) sink_("gimbal home"); return true; }
if (c == ":") { command_mode = true; cmd_cursor = 0; return true; }
// Spacebar: one image now, through the same pipeline a scan waypoint uses.
// The app refuses it outside manual; the bar just stops advertising it.
if (c == " ") { if (sink_) sink_("snapshot"); return true; }
if (c == "r") { if (sink_) sink_(capture_on ? "capture off" : "capture on"); return true; }
return false;
});
// 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);
// If the FTXUI loop exited on its own - e.g. the user hit Ctrl-C, which FTXUI
// catches - running_ is still set. Tell the app to shut down so the control-thread
// poll loop doesn't hang (which would force a kill, wedging the camera). When
// stop() drove the exit it already cleared running_, so this won't double-fire.
if (running_.exchange(false) && sink_) sink_("exit");
}
} // namespace fgc