952 lines
45 KiB
C++
952 lines
45 KiB
C++
#include "fgc/ui/TuiUi.h"
|
|
|
|
#include "fgc/DumpParser.h"
|
|
#include "fgc/HelpText.h"
|
|
#include "fgc/Logger.h"
|
|
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
#include <map>
|
|
#include <sstream>
|
|
#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/screen/color.hpp>
|
|
|
|
namespace fgc {
|
|
|
|
using namespace ftxui;
|
|
|
|
namespace {
|
|
|
|
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;
|
|
}
|
|
|
|
// "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 + " ")});
|
|
}
|
|
|
|
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::GrayLight),
|
|
badge("MOVE", a.moving, Color::Cyan),
|
|
badge("STALL", a.stall, Color::Red),
|
|
badge("OT", a.overtemp, Color::Red),
|
|
badge("L", a.endstop_l, Color::Yellow),
|
|
badge("R", a.endstop_r, Color::Yellow)}),
|
|
});
|
|
}
|
|
|
|
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, const std::string& hint) {
|
|
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(),
|
|
text(hint) | dim}));
|
|
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));
|
|
};
|
|
return panel("[i] SENSORS", Color::Magenta,
|
|
vbox({group(s.imu, "i to expand"),
|
|
separator(),
|
|
group(s.env, "")}));
|
|
}
|
|
|
|
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 = {
|
|
hbox({text("cameras ") | dim, text(std::to_string(c.camera_count) + " "),
|
|
text(labels) | dim}),
|
|
hbox({text("capture ") | dim, active, filler(),
|
|
text(std::to_string(c.image_rate) + " img/s") | dim}),
|
|
};
|
|
if (!c.scan_error.empty())
|
|
rows.push_back(hbox({text("scan ") | dim,
|
|
text(" GRID LOAD FAILED ") | color(Color::Red) | bold,
|
|
text(" c for details") | dim}));
|
|
if (c.has_last) {
|
|
long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::system_clock::now().time_since_epoch()).count();
|
|
rows.push_back(hbox({text("last ") | dim,
|
|
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(hbox({text("last ") | dim, text("—") | dim}));
|
|
}
|
|
return panel("CAMERA", 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) {
|
|
auto num = [](double v, int dec) {
|
|
char b[32];
|
|
std::snprintf(b, sizeof(b), "%.*f", dec, v);
|
|
return std::string(b);
|
|
};
|
|
auto row = [](const std::string& k, Element v) {
|
|
return hbox({text(k) | dim | size(WIDTH, EQUAL, 14), std::move(v)});
|
|
};
|
|
|
|
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")));
|
|
rows.push_back(row("output", text(c.config.output_dir.empty() ? "—" : c.config.output_dir) | dim));
|
|
|
|
// --- 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.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));
|
|
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(" white bal", text("R " + num(d.wb_red, 2) + " B " + num(d.wb_blue, 2))));
|
|
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,
|
|
})));
|
|
}
|
|
|
|
// --- Imaging config ---
|
|
const CameraConfigView& cf = c.config;
|
|
rows.push_back(separator());
|
|
rows.push_back(text("IMAGING CONFIG") | bold | color(Color::Blue));
|
|
rows.push_back(row("mode", text(cf.mock ? "MOCK (simulated)" : "real camera (Vimba X)")));
|
|
rows.push_back(row("format", text(cf.pixel_format + " binning " + std::to_string(cf.binning) + "x")));
|
|
rows.push_back(row("ROI", text((cf.width > 0 || cf.height > 0)
|
|
? (std::to_string(cf.width) + "x" + std::to_string(cf.height) +
|
|
" @ " + std::to_string(cf.offset_x) + "," +
|
|
std::to_string(cf.offset_y))
|
|
: "full sensor")));
|
|
rows.push_back(row("throughput", text(std::to_string(cf.throughput_mbytes) + " MB/s " +
|
|
"(stream " + num(cf.stream_fps, 1) + " fps)")));
|
|
rows.push_back(row("exposure", text(!cf.exposure_auto ? std::string("manual")
|
|
: cf.exposure_max_us > 0
|
|
? ("auto (max " + num(cf.exposure_max_us, 0) + " us)")
|
|
: "auto (no cap)")));
|
|
rows.push_back(row("gain", text(!cf.gain_auto ? std::string("manual")
|
|
: cf.gain_max_db > 0
|
|
? ("auto (max " + num(cf.gain_max_db, 1) + " dB)")
|
|
: "auto (no cap)")));
|
|
rows.push_back(row("white bal", text(cf.white_balance_auto ? "auto (continuous)" : "manual")));
|
|
rows.push_back(row("encoding", text("JPEG XL distance " + num(cf.jxl_distance, 2) +
|
|
" effort " + std::to_string(cf.jxl_effort))));
|
|
|
|
// --- 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))));
|
|
}
|
|
|
|
// --- Scan grid ---
|
|
rows.push_back(separator());
|
|
rows.push_back(hbox({text("SCAN GRID ") | bold | color(Color::Blue),
|
|
text(c.scan_from_file ? "(CSV file)" : "(generated)") | dim, 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(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan),
|
|
vbox(std::move(rows)) | yframe);
|
|
}
|
|
if (c.scan_grid.empty()) {
|
|
rows.push_back(text("(no scan grid defined — auto-sweep disabled)") | dim);
|
|
return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan),
|
|
vbox(std::move(rows)) | yframe);
|
|
}
|
|
|
|
// " # | yaw / pitch", current row inverted. Pitch column dropped on 1-axis.
|
|
constexpr size_t kRows = 18; // rows per column before wrapping
|
|
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(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan),
|
|
vbox(std::move(rows)) | yframe);
|
|
}
|
|
|
|
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);
|
|
std::string mode = v.control_code == 0 ? "0 auto-sweep" : "1 directed";
|
|
return panel("CONNECTIVITY", Color::Green, vbox({
|
|
hbox({text("MQTT ") | dim, mqtt, filler(), text(v.broker) | dim}),
|
|
hbox({text("tower ") | dim, text(v.tower)}),
|
|
hbox({text("control ") | dim, text(mode),
|
|
filler(), text("hdg " + v.target_heading) | dim}),
|
|
hbox({text("status ") | dim, text(std::to_string(v.last_status_code))}),
|
|
}));
|
|
}
|
|
|
|
Element logPanel(const std::vector<LogLine>& lines) {
|
|
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);
|
|
return window(text(" LOG ") | bold | color(Color::GrayLight),
|
|
vbox(std::move(rows)) | focusPositionRelative(0, 1) | yframe);
|
|
}
|
|
|
|
// 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) {
|
|
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),
|
|
}));
|
|
} 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)));
|
|
}
|
|
|
|
// Inline help pane (toggled with '?'). Lists every command section; the
|
|
// `sel`-th section is expanded to show each entry's detail. The Diagnostics
|
|
// section additionally renders the last captured firmware DUMP block.
|
|
Element helpPanel(int sel, const DumpView& dump) {
|
|
const auto& cat = helpCatalog();
|
|
std::vector<Element> rows;
|
|
for (int i = 0; i < static_cast<int>(cat.size()); ++i) {
|
|
const HelpSection& sec = cat[i];
|
|
const bool open = (i == sel);
|
|
Element title = hbox({
|
|
text(open ? " v " : " > "),
|
|
text(sec.title) | bold,
|
|
text(" " + sec.blurb) | dim,
|
|
});
|
|
rows.push_back(open ? (title | inverted) : title);
|
|
if (!open) continue;
|
|
|
|
for (const auto& e : sec.entries) {
|
|
rows.push_back(hbox({text(" "), text(e.syntax) | color(Color::Cyan) | bold}));
|
|
rows.push_back(hbox({text(" "), text(e.summary) | dim}));
|
|
for (const auto& d : e.detail)
|
|
rows.push_back(hbox({text(" "), text(d) | dim}));
|
|
}
|
|
// Diagnostics: show the most recent firmware dump inline.
|
|
if (sec.title == "Diagnostics") {
|
|
rows.push_back(text(" --- last firmware dump ---") | bold);
|
|
if (dump.has) {
|
|
std::istringstream iss(dump.text);
|
|
std::string l;
|
|
while (std::getline(iss, l))
|
|
rows.push_back(hbox({text(" "), text(l) | color(Color::Green)}));
|
|
} else {
|
|
rows.push_back(hbox({text(" "),
|
|
text("(none captured yet - run 'dump')") | dim}));
|
|
}
|
|
}
|
|
rows.push_back(text(""));
|
|
}
|
|
return window(text(" HELP (?:close Up/Down:section) ") | bold | color(Color::Cyan),
|
|
vbox(std::move(rows)) | yframe);
|
|
}
|
|
|
|
// 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::GrayLight),
|
|
badge("MOVE", a.moving, Color::Cyan),
|
|
badge("STALL", a.stall, Color::Red),
|
|
badge("OT", a.overtemp, Color::Red),
|
|
badge("L", a.endstop_l, Color::Yellow),
|
|
badge("R", a.endstop_r, Color::Yellow)}),
|
|
};
|
|
// 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,
|
|
text(" eeprom=" + std::to_string(ax.eeprom_restored ? 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 DiagAxisView* diag) {
|
|
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));
|
|
if (calib_has && cal.ok) {
|
|
col.push_back(kvRow("cnt/deg", fmt("%.3f", cal.counts_per_deg)));
|
|
col.push_back(kvRow("zero", std::to_string(cal.zero_count)));
|
|
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)));
|
|
} else {
|
|
col.push_back(text(calib_has ? "fit failed" : "uncalibrated this session") | dim);
|
|
}
|
|
|
|
return panel(title, Color::Cyan, vbox(std::move(col)) | yframe);
|
|
}
|
|
|
|
// 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) {
|
|
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;
|
|
};
|
|
|
|
std::string reset;
|
|
for (size_t i = 0; i < d.reset_flags.size(); ++i)
|
|
reset += (i ? " " : "") + d.reset_flags[i];
|
|
std::string calib_note = "uncalibrated this session";
|
|
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, diagFor('Y')));
|
|
if (g.pitch_present)
|
|
cols.push_back(axisColumn("PITCH", g.pitch, d, 'P', calib.has, calib.pitch, diagFor('P')));
|
|
|
|
return window(text(" GIMBAL (g/Esc:close d:refresh dump) ") | bold | color(Color::Cyan),
|
|
vbox({header, separator(), hbox(std::move(cols)) | flex}));
|
|
}
|
|
|
|
// Full-screen Sensors view (toggled with 'i'): 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) {
|
|
// 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("check [Features] enable_imu and [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) +
|
|
" \xE2\x80\x94 ambient (I2C)") | bold | color(Color::Magenta));
|
|
if (!e.enabled) {
|
|
body.push_back(text("(env sensor disabled)") | dim);
|
|
body.push_back(
|
|
text("enable with [Features] enable_env and [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.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));
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
if (c.present) {
|
|
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));
|
|
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));
|
|
// yframe so a long body (MTi channels + config + ambient) stays reachable on a
|
|
// short terminal instead of being silently cut off, as the camera view does.
|
|
return window(text(" SENSORS (i/Esc:close r:refresh config) ") | bold | color(Color::Magenta),
|
|
vbox({hbox({imu_status, text(" "), env_status, filler()}), separator(),
|
|
vbox(std::move(body)) | yframe | 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();
|
|
}
|
|
|
|
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;
|
|
if (paused_) continue; // frozen for text selection; don't repaint
|
|
// 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;
|
|
bool command_mode = false;
|
|
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
|
|
bool calib_prompt = false; // a yes/no calib-save question is showing
|
|
|
|
auto input = Input(&cmd_buffer, "type a command, Enter to run, Esc to cancel");
|
|
|
|
auto renderer = Renderer(input, [&] {
|
|
UiSnapshot s = snapshot_ ? snapshot_() : UiSnapshot{};
|
|
{
|
|
std::lock_guard<std::mutex> lock(log_mutex_);
|
|
s.log.assign(log_.begin(), log_.end());
|
|
}
|
|
calib_prompt = !s.activity.prompt.empty();
|
|
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(),
|
|
(paused_ ? text(" PAUSED ") | color(Color::Black) | bgcolor(Color::Yellow) | bold
|
|
: text("")),
|
|
text(" " + mode + " ") | (s.header.live ? color(Color::Green) : color(Color::Yellow)) | bold,
|
|
text(" up " + std::to_string(s.header.uptime_ms / 1000) + "s ") | dim,
|
|
});
|
|
|
|
Element top = hbox({gimbalPanel(s.gimbal), sensorsPanel(s.sensors)});
|
|
Element middle = hbox({cameraPanel(s.capture), connPanel(s.conn)});
|
|
|
|
Element bottom;
|
|
if (command_mode) {
|
|
bottom = hbox({text(" : ") | inverted, input->Render() | flex}) | border;
|
|
} else {
|
|
bottom = hbox({
|
|
keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"),
|
|
keyHint("g", "Gimbal"), keyHint("i", "Sensors"), keyHint("c", "Scan"),
|
|
keyHint("p", "Pause"), keyHint("r", "Refresh"),
|
|
keyHint("\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93", "Nudge"),
|
|
keyHint(":", "Cmd"), keyHint("?", "Help"), filler(), keyHint("q", "Quit"),
|
|
});
|
|
}
|
|
|
|
// 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_sel, s.dump) | flex, bottom});
|
|
case Overlay::Gimbal:
|
|
return vbox({header, separator(),
|
|
gimbalDetailPanel(s.gimbal, s.dump, s.calib, s.diag) | flex, bottom});
|
|
case Overlay::Sensors:
|
|
return vbox({header, separator(),
|
|
sensorsDetailPanel(s.imu, s.env) | 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.
|
|
std::vector<Element> col = {header, separator(), top, middle,
|
|
logPanel(s.log) | flex};
|
|
if (s.activity.active || s.activity.has_result || !s.activity.prompt.empty())
|
|
col.push_back(activityPanel(s.activity));
|
|
col.push_back(bottom);
|
|
return vbox(std::move(col));
|
|
}
|
|
}
|
|
});
|
|
|
|
auto root = CatchEvent(renderer, [&](Event e) {
|
|
if (command_mode) {
|
|
if (e == Event::Return) {
|
|
if (!cmd_buffer.empty() && sink_) sink_(cmd_buffer);
|
|
cmd_buffer.clear();
|
|
command_mode = false;
|
|
return true;
|
|
}
|
|
if (e == Event::Escape) {
|
|
cmd_buffer.clear();
|
|
command_mode = false;
|
|
return true;
|
|
}
|
|
return false; // let the Input edit the buffer
|
|
}
|
|
const int n = static_cast<int>(helpCatalog().size());
|
|
if (overlay == Overlay::Help) { // help pane navigation
|
|
if (e == Event::ArrowDown) { help_sel = (help_sel + 1) % n; return true; }
|
|
if (e == Event::ArrowUp) { help_sel = (help_sel - 1 + n) % n; return true; }
|
|
}
|
|
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; }
|
|
}
|
|
// Arrow keys nudge the gimbal in steps (only when no overlay is open):
|
|
// Left/Right = yaw -/+5%, Up/Down = pitch -/+10% of travel.
|
|
if (overlay == Overlay::None && 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) { sink_("gimbal nudge pitch -10"); return true; }
|
|
if (e == Event::ArrowDown) { 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; }
|
|
}
|
|
if (c == "?") {
|
|
overlay = (overlay == Overlay::Help) ? Overlay::None : Overlay::Help;
|
|
return true;
|
|
}
|
|
if (c == "g") {
|
|
if (overlay == Overlay::Gimbal) {
|
|
overlay = Overlay::None;
|
|
} else {
|
|
overlay = Overlay::Gimbal;
|
|
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 == "i") {
|
|
overlay = (overlay == Overlay::Sensors) ? Overlay::None : Overlay::Sensors;
|
|
return true;
|
|
}
|
|
if (c == "c") {
|
|
overlay = (overlay == Overlay::Cameras) ? Overlay::None : Overlay::Cameras;
|
|
return true;
|
|
}
|
|
if (c == "p") { // freeze/unfreeze the live refresh so text can be selected
|
|
paused_ = !paused_;
|
|
return true; // this keypress redraws once so the indicator updates
|
|
}
|
|
if (overlay == Overlay::Help) { // vim-style section nav while help is open
|
|
if (c == "j") { help_sel = (help_sel + 1) % n; return true; }
|
|
if (c == "k") { help_sel = (help_sel - 1 + n) % n; return true; }
|
|
}
|
|
if (c == "q") { if (sink_) sink_("exit"); return true; }
|
|
if (c == "s") { if (sink_) sink_("start"); return true; }
|
|
if (c == "x") { if (sink_) sink_("stop"); return true; }
|
|
if (c == "h") { if (sink_) sink_("gimbal home"); return true; }
|
|
if (c == "r") { if (sink_) sink_("refresh"); return true; } // IMU config + dump
|
|
if (c == ":") { command_mode = true; 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
|