#include "fgc/ui/TuiUi.h" #include "fgc/DumpParser.h" #include "fgc/HelpText.h" #include "fgc/Logger.h" #include #include #include #include #include #include #include #include #include #include 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 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 DHT11 (ambient) // clearly separated. auto group = [](const SensorGroup& g, const std::string& hint) { std::vector 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.dht, "")})); } 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 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.has_last) { long long now = std::chrono::duration_cast( 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))); } 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& lines) { std::vector 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 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 rows; for (int i = 0; i < static_cast(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& 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 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("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& 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& 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) { auto fmt = [](const char* f, double v) { char b[32]; std::snprintf(b, sizeof(b), f, v); return std::string(b); }; std::vector 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); // 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) { DumpData d = parseDump(dump.text); 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::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 cols; cols.push_back(axisColumn("YAW", g.yaw, d, 'Y', calib.has, calib.yaw)); if (g.pitch_present) cols.push_back(axisColumn("PITCH", g.pitch, d, 'P', calib.has, calib.pitch)); return window(text(" GIMBAL (g/Esc:close d:refresh dump) ") | bold | color(Color::Cyan), vbox({header, separator(), hbox(std::move(cols)) | flex})); } // Full-screen IMU view (toggled with 'i'): every MTi channel with units. Element imuDetailPanel(const ImuView& v) { auto f2 = [](float x) { char b[24]; std::snprintf(b, sizeof(b), "%.2f", x); return std::string(b); }; // 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])) | color(c) | size(WIDTH, EQUAL, 12), text("y " + f2(xyz[1])) | color(c) | size(WIDTH, EQUAL, 12), text("z " + f2(xyz[2])) | color(c) | size(WIDTH, EQUAL, 12), }); }; const float ori[3] = {v.roll_deg, v.pitch_deg, v.yaw_deg}; std::vector body; 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])) | bold | size(WIDTH, EQUAL, 14), text("pitch " + f2(ori[1])) | bold | size(WIDTH, EQUAL, 14), text("yaw " + f2(ori[2])) | bold | size(WIDTH, EQUAL, 14), })); 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") | dim | size(WIDTH, EQUAL, 14), text(f2(v.temp_c) + " \xC2\xB0""C") | bold | size(WIDTH, EQUAL, 18), text("sample #" + std::to_string(v.sample_counter)) | dim, })); } Element status = v.present ? (text(" MTi live ") | color(Color::Green) | bold) : (text(" MTi offline ") | color(Color::Red) | bold); return window(text(" IMU (i/Esc:close) ") | bold | color(Color::Magenta), vbox({hbox({status, filler()}), separator(), vbox(std::move(body)) | 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 lock(log_mutex_); log_.push_back({level, line}); while (log_.size() > kLogCap) log_.pop_front(); } void TuiUi::refreshLoop() { using namespace std::chrono_literals; while (running_) { std::this_thread::sleep_for(100ms); if (!running_) break; 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 }; 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 lock(log_mutex_); s.log.assign(log_.begin(), log_.end()); } calib_prompt = !s.activity.prompt.empty(); 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(), 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", "IMU"), 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) | flex, bottom}); case Overlay::Sensors: return vbox({header, separator(), imuDetailPanel(s.imu) | 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 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(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; } // 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 (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_("gimbal reset"); return true; } 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(); screen_.store(&screen); if (running_) screen.Loop(root); screen_.store(nullptr); } } // namespace fgc