Rework the dashboard keys: a/p/m for modes, n for connectivity, page keys to scroll, heading setup in the activity strip

This commit is contained in:
pgdalmeida 2026-09-25 18:57:25 +02:00
parent f4e67666d8
commit 79225bc3cd
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
18 changed files with 456 additions and 511 deletions

View File

@ -205,31 +205,29 @@ press a key that acts.
| Panel | What it shows |
|---|---|
| **Header** | the tower's name, the program's build, `LIVE` (or `PAUSED`), how long it has been up |
| **Header** | the tower's name, the program's build, its mode (`AUTO`, `PAN`, `MANUAL`), `LIVE` (or `PAUSED`), how long it has been up |
| **GIMBAL** `g` | each axis: its state (`READY`, `HOMING`, `ERROR`...), the angle it is at and the one it is heading for, the motor and encoder counts, and flags - `STILL`/`MOVE`, `STALL`, over-temperature `OT`, endstops `L`/`R` |
| **SENSORS** `i` | the orientation sensor (roll, pitch, yaw) and the ambient temperature and humidity, each marked `live` or not |
| **CAMERA** `c` | whether it is capturing and how fast, the active profile, and the last picture: which camera, where it pointed, how long ago |
| **COMMS** `m` | the broker connection, the tower name the messages carry, the control mode |
| **LOG** | the latest log lines; warnings in yellow, errors in red. `PgUp` scrolls back, `End` follows again |
| **ACTIVITY** | what is running right now (a sweep, a homing, a test) and the result of the last one |
| **SENSORS** `s` | the orientation sensor (IMU) (roll, pitch, yaw) and the ambient temperature and humidity, each marked `live` or not |
| **CAMERAS** `c` | whether it is capturing and how fast, the active profile, and the last picture: which camera, where it pointed, how long ago |
| **CONNECTIVITY** `n` | the broker connection, the tower name the messages carry, the mode as published |
| **LOG** | the latest log lines, and in its title the file they are written to; warnings in yellow, errors in red. It always shows the newest line - pause (`f`) to page back |
| **ACTIVITY** | what is running right now (a sweep, a pan, a homing, a test, a heading setup) and the result of the last one |
| **Key bar** | the keys that do something in this view |
### Keys
| Key | Does |
|---|---|
| `s` / `x` | auto mode (sweep and capture) / manual mode (stop, take it by hand) |
| `a` / `p` / `m` | auto mode (sweep the scan grid) / pan 30° around where the gimbal points now / manual mode (stop, take it by hand) |
| `h` | home the gimbal |
| `r` | refresh: ask the firmware for a full status dump, re-read the sensor setup |
| `g` `i` `c` `m` | open the full-screen gimbal, sensors, camera or comms view; press it again to close |
| `?` | the built-in help: every command, with examples |
| `g` `s` `c` `n` | open the full-screen gimbal, sensors, cameras or connectivity view; press it again to close |
| `?` | the built-in help: every command, with examples (`j`/`k` change section) |
| `:` | type a command, exactly as in the [command list](#everyday-commands) |
| `p` | pause the display, so you can select and copy text (the program keeps running) |
| `PgUp` `PgDn` `Home` `End` | scroll the log |
| arrows | nudge the gimbal by hand - only when no sweep is running |
| `Space` | take one picture now - only when no sweep is running |
| `H` | set up north: aim at known landmarks and type their bearings - only when no sweep is running |
| `Esc` | close a view; with no view open, **cancel** whatever is running (a test, a homing, a calibration) |
| `f` | freeze the display, to select and copy text or to page back through the log (the program keeps running) |
| `PgUp` `PgDn` | scroll the log, or the open view - the only scroll keys |
| arrows | nudge the gimbal by hand, from any view - manual mode only |
| `Space` | take one picture now - manual mode only |
| `Esc` | close a view; with no view open, **cancel** whatever is running (a test, a homing, a calibration, a heading setup) |
| `q` | quit or background the program - it asks which |
| `Ctrl-b d` | leave the dashboard and keep the program running (a `tmux` key) |
@ -242,22 +240,22 @@ full dump; `d` refreshes it.
![The gimbal view](docs/images/readme/gimbal.png)
**Sensors** (`i`) - the orientation sensor's full output (acceleration, turn
**Sensors** (`s`) - the orientation sensor's (IMU's) full output (acceleration, turn
rate, magnetic field, its internal temperature) and configuration, and the
ambient sensor with the bus it is on.
![The sensors view](docs/images/readme/sensors.png)
**Camera** (`c`) - the camera itself (model, frame size, exposure, gain,
**Cameras** (`c`) - the camera itself (model, frame size, exposure, gain,
temperature, frames delivered and dropped, stream restarts), its profile and
encoding, and the whole scan grid with the current waypoint marked.
![The camera view](docs/images/readme/camera.png)
**Comms** (`m`) - the broker connection, message counters, the topics
**Connectivity** (`n`) - the broker connection, message counters, the topics
the tower listens and speaks on, and the most recent messages, exactly as sent.
![The comms view](docs/images/readme/comms.png)
![The connectivity view](docs/images/readme/comms.png)
**Help** (`?`) - every command, grouped, with an explanation and an example.
The same text is available anywhere as `help` or `help <topic>`.

View File

@ -125,7 +125,18 @@ Aim at landmarks whose true bearing you know (a mast, a peak, a road junction on
the encoder position against each bearing. **No IMU is required**, which is the point: not every tower
has one, and MTi yaw drifts too much to be a heading reference.
In the TUI press **`H`** (manual mode only):
It is four commands, so it works the same at the console, on the dashboard and over MQTT (manual
mode only):
| Command | Does |
|---|---|
| `gimbal heading setup` | start: claims the gimbal, so no sweep can start under it |
| `gimbal heading mark <bearing>` | record where the gimbal points now as that landmark's true bearing |
| `gimbal heading fit` | fit the marks and apply the result |
| `gimbal heading cancel` | stop and drop the marks (so do `cancel` and Esc) |
On the dashboard, type `:gimbal heading setup`; it then runs in the activity strip, which shows the
position, the step size and the marks so far, and these keys apply:
| Key | Action |
|---|---|
@ -133,7 +144,7 @@ In the TUI press **`H`** (manual mode only):
| `↑` `↓` | Aim pitch (centre the landmark vertically) |
| `Tab` | Cycle step size: coarse (5% of travel) → fine (0.5%) → very fine (25 counts, ≈0.05°) |
| `Space` | Record an image + `CamEvent`, exactly as a scan waypoint would |
| `Enter` | Freeze the position and type that landmark's true bearing |
| `Enter` | Open the command line with `gimbal heading mark ` filled in: type the bearing, Enter |
| `f` | Fit and apply |
| `Esc` | Cancel |
@ -196,7 +207,7 @@ During that same Config-state handshake the host also **reads back the device co
filter-profile / **XKF profile** via `ReqFilterProfile` + `ReqAvailableFilterProfiles`). The decoded
values — product code, firmware, device ID, output mode/format, calibration channels, sample rate, and
the list of supported **Xsens Kalman Filter (XKF) profiles** with the active one marked — are surfaced
in the **IMU CONFIG** section of the expanded Sensors view (press `i`). This is read-only: it reports
in the **IMU CONFIG** section of the expanded Sensors view (press `s`). This is read-only: it reports
what the device is actually configured to do, which is the place to confirm the active XKF profile
(e.g. `General` vs `VRU_general`) when diagnosing yaw drift. On the legacy MTi the same concept is
called a "scenario" in the device manual (e.g. `Machine_nomagfield`); it is the same setting and shares
@ -204,8 +215,8 @@ the wire MIDs. Older MTi firmware that does not answer the `Req*` queries simply
absent (the host logs `no configuration acks received`).
The config is read **once at startup** and cached, so if you change the XKF profile externally the view
stays stale until you **refresh** it: press `r` (or type `refresh`). That re-queries the device (briefly
pausing the stream) and also requests a fresh firmware dump for the gimbal `g` view.
stays stale until you re-read it with `imu config`, which re-queries the device (briefly pausing the
stream).
#### The device configuration is verified, not assumed
@ -227,7 +238,7 @@ system — since neither changes the payload length. A device that never answere
instead of being dropped.
A failed check never stops the program: it logs at `ERROR`, shows a red **MISCONFIGURED**
row in the **IMU CONFIG** section of the expanded Sensors view (`i`), a red ` IMU CFG `
row in the **IMU CONFIG** section of the expanded Sensors view (`s`), a red ` IMU CFG `
badge on the compact Sensors panel, and repeats in `imu config`. The persistent surfaces
matter because the handshake runs *before* the TUI installs its log sink, so the log line
alone is wiped by the alt-screen.
@ -323,7 +334,7 @@ as two of them are non-zero.
| upside down and facing backwards | 180 | 0 | 180 |
Values outside ±360° are rejected at startup, and a non-zero mount is logged at INFO so the session's
log records which frame its readings are in. The expanded Sensors view (`i`) shows the mount in force
log records which frame its readings are in. The expanded Sensors view (`s`) shows the mount in force
on the `Mount (cfg)` row of **IMU CONFIG** — labelled `(cfg)` because, unlike everything else in that
section, it comes from `config.ini` rather than from the device.
@ -609,8 +620,9 @@ logs on stdout.
Dashboard panels: **Gimbal** (per-axis state, heading, encoder counts, flag badges, target),
**Sensors** (the **Xsens MTi** shows live roll/pitch/yaw once `enable_imu`; the ambient
**SHT41** shows live temp/humidity once `enable_env`. The SHT41's is the **only** temperature on the main window: the MTi's
device-internal temp is diagnostic detail and appears solely in the expanded view), **Camera** (count, capture state, rate, last capture), **Comms** (MQTT state,
broker, tower, control mode, target heading). Adding a panel later is a struct in `UiSnapshot.h` plus
device-internal temp is diagnostic detail and appears solely in the expanded view), **Cameras** (count, capture state, rate, last capture), **Connectivity** (MQTT state,
broker, tower, the mode as published on StatusCode). The **LOG** title names the file the log is being
written to, and follows the newest line. Adding a panel later is a struct in `UiSnapshot.h` plus
one node in [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp).
**Expanded takeover views** replace the dashboard body full-screen (`Esc` or the same key closes):
@ -622,11 +634,11 @@ one node in [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp).
enabled), the active **profile** and the others available, the `profile_dir` and the exact XML
applied, the per-profile `settle_wait_ms`, `rotation_deg` and the JPEG XL settings, plus live
per-device telemetry (dimensions, streaming, frames, drops) and the scan grid.
- **Comms** (`m`) — broker, tower, client id, connection state and control mode, the
- **Connectivity** (`n`) — broker, tower, client id, connection state and mode, the
delivery counters (sent / failed / connects / losses), and a **history of the last MQTT
messages** — time, topic leaf and payload — so a publish can be confirmed without a broker-side
subscriber. The compact Comms panel carries the `m` badge that opens it.
- **Sensors** (`i`) — both sensors, in headed sections so every reading is attributable.
subscriber. The compact Connectivity panel carries the `n` badge that opens it.
- **Sensors** (`s`) — both sensors, in headed sections so every reading is attributable.
**MTi**: every channel with units — orientation (°), acceleration (m/s²), rate-of-turn
(rad/s), magnetic field (a.u.), `TEMP (internal)`, sample counter, plus an **IMU CONFIG** section
(read back from the device at startup): product code, firmware, device ID, output mode/format,
@ -637,8 +649,8 @@ one node in [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp).
(`enable_env` / `--mock-env`) or, when enabled but silent, the staleness window that was missed.
**Activity strip** — a compact section between the log and the key bar that shows the
currently-running special operation with live progress (`test`, `gimbal pitch setup`, homing, capture
scan) **and persists the last test/calibration result** (PASS/FAIL, age) so it doesn't scroll
currently-running special operation with live progress (`test`, `gimbal pitch setup`, homing, the
auto sweep, a pan, heading setup) **and persists the last test/calibration result** (PASS/FAIL, age) so it doesn't scroll
away in the log. It only appears once something has run. While a procedure is running, **Esc**
cancels it.
@ -654,12 +666,17 @@ the original, so an interrupted save cannot truncate your config.
`gimbal heading offset` saves immediately without asking — it is a one-line correction, not a fit.
**The key bar is contextual**: it advertises the keys that are useful in the current view, not every
binding. `s` auto and `x` manual lead everywhere — the mode can be changed from any view —
and `:` command, `?` help and `q` quit close every set. The overview adds `h` home, `r` refresh,
`g i c m` views, `p` pause and the arrow keys (nudge the gimbal, yaw ±5 % / pitch ±10 %); an
expanded view instead offers its own (`d` dump in the gimbal view, `r` refresh in sensors, the
scroll keys, `Esc` close). Every binding still *works* everywhere; only the advertising changes.
`p` freezes the live refresh so text can be selected and copied.
binding. `a` auto, `p` pan (30° around where the gimbal points now) and `m` manual lead everywhere —
the mode can be changed from any view — and `:` command, `?` help and `q` quit close every set. The
overview adds `h` home, `g s c n` views and `f` pause; an expanded view offers its own (`d` dump in
the gimbal view, `j`/`k` sections in help, `Esc` close). In manual mode the arrow keys nudge the gimbal
(yaw ±5 % / pitch ±10 %) from any view, and `Space` takes a picture. Every binding still *works*
everywhere; only the advertising changes.
**Scrolling is `PgUp`/`PgDn` only**, the arrows being the gimbal's. The log, the message history and a
freshly pulled gimbal dump jump to their newest line whenever one arrives, so to read further back,
pause first: `f` freezes the whole frame (log included) until `f` again, and the header says when it
froze. It is also how to select and copy text.
**`q` asks rather than quitting.** It opens a prompt: **`q`** quit (stop capture, close the camera,
end the program), **`b`** background (detach and leave it running — the same as `Ctrl-b d`, shown

View File

@ -181,7 +181,7 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM
wanted.
- Ambient temperature/humidity (SHT41, `IEnvSensor`/`Sht41EnvSensor`/`MockEnvSensor`) is fully
integrated and **validated against the physical sensor on the LattePanda** — config-gated
(`[Features] enable_env`, `[Env]`), Sensors panel + expanded `i` view + MQTT `Env` topic wired.
(`[Features] enable_env`, `[Env]`), Sensors panel + expanded `s` view + MQTT `Env` topic wired.
It is **off by default** (`enable_env = false`), so a config without an `[Env]` section shows the
panel as `pending` — that status means "no driver constructed", not "sensor unreachable"; an
enabled-but-silent sensor reads `no fix` instead. Dev machines without the hardware should use

View File

@ -32,7 +32,7 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [include/fgc/CameraProfile.h](../include/fgc/CameraProfile.h), [src/core/CameraProfile.cpp](../src/core/CameraProfile.cpp) | `loadCameraProfile` / `loadCameraProfiles`: one VimbaX settings XML per profile. Hashes the XML, regenerates the `.txt` record when it changes, and reports which settings moved. A missing XML throws — the record is never a fallback |
| [include/fgc/CameraSettingsXml.h](../include/fgc/CameraSettingsXml.h), [src/core/CameraSettingsXml.cpp](../src/core/CameraSettingsXml.cpp) | Parses the Viewer export's `<RemoteDevice>` features (ordered, selector-aware), renders the human-readable `.txt` record, reads one back, and diffs two settings lists. Used for display and change detection only — never to configure a camera |
| [include/fgc/ChannelTopics.h](../include/fgc/ChannelTopics.h), [src/core/ChannelTopics.cpp](../src/core/ChannelTopics.cpp) | The MQTT wire format in one place: topic builders for the `GGS/FWT/<tower>/…` tree and the `CamEvent`/status payload builders. Pure, so the documented format is unit-tested rather than assembled inline at each publish site |
| [include/fgc/ChannelHistory.h](../include/fgc/ChannelHistory.h), [src/core/ChannelHistory.cpp](../src/core/ChannelHistory.cpp) | Ring buffer of recently published messages (time, topic, payload, outcome) plus the delivery counters — sent, failed, connects, losses, last status. Feeds the expanded Comms view so a publish can be confirmed without a broker-side subscriber |
| [include/fgc/ChannelHistory.h](../include/fgc/ChannelHistory.h), [src/core/ChannelHistory.cpp](../src/core/ChannelHistory.cpp) | Ring buffer of recently published messages (time, topic, payload, outcome) plus the delivery counters — sent, failed, connects, losses, last status. Feeds the expanded Connectivity view so a publish can be confirmed without a broker-side subscriber |
| [include/fgc/Hash.h](../include/fgc/Hash.h), [src/core/Hash.cpp](../src/core/Hash.cpp) | FNV-1a 64 over a string or a file, with an algorithm-prefixed label. Change detection for the profile records, deliberately not a crypto hash |
| [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces; injectable clock; `setGeometry` adopts a recalibration |
| [include/fgc/Application.h](../include/fgc/Application.h), [src/core/Application.cpp](../src/core/Application.cpp) | Factory (real vs mock, headless vs TUI), wiring, control loop, `gimbal …` commands, background-result polling, `buildSnapshot()` |
@ -110,7 +110,7 @@ homing or `gimbal reset`, so a lit badge means "position untrustworthy since the
One ambient reading: `valid`, `temp_c` (°C), `humidity_pct` (0..100), `timestamp_ms` (epoch ms).
`IEnvSensor::sample()` returns `nullopt` rather than a stale sample once nothing valid has arrived
within 2× the configured poll period, so a silently dead sensor reads as absent, not as a frozen
value. Surfaced as `EnvView` in the TUI (`i` view) and as `SensorsView::env` in the compact panel.
value. Surfaced as `EnvView` in the TUI (`s` view) and as `SensorsView::env` in the compact panel.
### `Frame` ([ICameraSource.h](../include/fgc/ICameraSource.h))
Owned pixel buffer + `width`, `height`, `channels` (1 or 3), `timestamp_ms`, `cam_id`.
@ -129,7 +129,7 @@ the device actually answered): `product_code`, `device_id`, `firmware`, output m
per-channel `acc/gyr/mag_enabled`, `data_format`), `period`/`sample_rate_hz`, and the **XKF scenario**
(`scenario_type`/`version`, resolved to `scenario_label` against `available_profiles`). Built by
`applyImuConfigAck` (one ack frame at a time) + `finalizeImuConfig` (derive rate, resolve label).
Surfaced read-only in the TUI's `i` view (IMU CONFIG section); the legacy "scenario" is the modern
Surfaced read-only in the TUI's `s` view (IMU CONFIG section); the legacy "scenario" is the modern
"filter profile" — same MIDs (`0x62/0x64/0x65`).
## On-disk artifacts

View File

@ -31,7 +31,7 @@ are **retained**, except command replies.
The tower is always in one of three modes. It changes mode **only by command** -
`mode ...`, sent on the `Command` topic below, typed at the console, or the
dashboard's `s` / `x` keys - and reports it on `StatusCode`.
dashboard's `a` / `p` / `m` keys - and reports it on `StatusCode`.
| Mode | StatusCode | What the tower does | Command |
|---|---|---|---|
@ -59,7 +59,7 @@ reachable arc is logged after every home.
**What each mode accepts.** Manual accepts every command. Auto and pan accept
only what cannot move the gimbal or change a setting:
- reading: `help`, `gimbal status`, `gimbal dump`, `imu status`, `imu config`, `refresh`
- reading: `help`, `gimbal status`, `gimbal dump`, `imu status`, `imu config`
- logging: `debug`, `trace`
- `mode ...`, `cancel`, `exit`

View File

@ -34,7 +34,7 @@ token that names a configured profile selects the profile at any position.
| `test strict` | everything, `strict` profile |
While a test runs, interfering commands (moves, homing, a second test, capture,
`refresh`) are ignored; **Esc** (or `cancel`, or `x`) cancels. Pure-UI keys (`p`, `i`, `c`,
`gimbal dump`) are ignored; **Esc** (or `cancel`, or `m`) cancels. Pure-UI keys (`f`, `s`, `c`,
`g`, `?`) keep working.
## Subsystems and leaves

View File

@ -11,7 +11,7 @@ table.
The ambient sensor is an SHT41 on the LattePanda's **own I2C bus** (not the Arduino) — see
[configuration.md](configuration.md) `[Env]`. The software side has landed: `IEnvSensor` /
`Sht41EnvSensor` / `MockEnvSensor`, config-gated, wired into the Sensors panel, the expanded `i`
`Sht41EnvSensor` / `MockEnvSensor`, config-gated, wired into the Sensors panel, the expanded `s`
view and the MQTT `Env` topic; the wire protocol is pure and unit-tested
([Sht41Protocol](../include/fgc/sensors/Sht41Protocol.h), `tests/test_sht41.cpp`). The real backend
has been **validated against the physical sensor on the LattePanda**, so this leaf is no longer

View File

@ -3,6 +3,7 @@
#include "fgc/Config.h"
#include <memory>
#include <functional>
#include <optional>
#include <string>
@ -28,6 +29,9 @@ struct RuntimeOptions {
std::string log_level; // empty => default
std::string trace_categories; // comma list (serial,mqtt,camera,control,all); empty => unset
std::string config_path; // resolved config.ini path (to save calibration back); empty => none
// The log file being written now (it rotates), for the dashboard's LOG title.
// Unset => no log file.
std::function<std::string()> log_file;
// --test: run this one self-test selection (the words after `test` at the
// console, e.g. "hardware" or "hardware motor") once the motor link is up,

View File

@ -3,6 +3,7 @@
#include "fgc/Logger.h"
#include <cstdio>
#include <mutex>
#include <string>
#include <vector>
@ -67,7 +68,12 @@ public:
void close();
bool isOpen() const { return fp_ != nullptr; }
std::string path() const { return path_; }
// The file being written now. Safe from any thread: rotation changes it
// under Logger's lock, while the dashboard reads it for the LOG title.
std::string path() const {
std::lock_guard<std::mutex> lock(path_mutex_);
return path_;
}
// Set when an open, write or rotate failed. The sink then stops writing
// rather than retrying every line. Poll this from outside Logger's lock.
bool failed() const { return failed_; }
@ -86,7 +92,8 @@ private:
std::FILE* fp_ = nullptr;
FileLogPolicy policy_;
std::string header_;
std::string path_;
std::string path_; // written under path_mutex_; read freely by this sink
mutable std::mutex path_mutex_;
std::string base_; // path without the ".<NNN>.log" suffix
long long bytes_ = 0;
long long lines_ = 0; // lines in the CURRENT file; guards rotation loops

View File

@ -73,7 +73,7 @@ struct SensorField {
// A labelled group of readings from one physical sensor, rendered as its own
// subsection in the Sensors panel.
struct SensorGroup {
std::string title; // "MTi (orientation)" / "SHT41 (ambient)"
std::string title; // "IMU (orientation)" / "SHT41 (ambient)"
std::string status = "pending"; // "live" / "pending" / "no fix"
bool present = false; // true once the driver feeds real data
std::vector<SensorField> fields;
@ -279,6 +279,10 @@ struct ActivityView {
UiColor result_color = UiColor::Default; // green pass / red fail
std::string prompt; // a yes/no question awaiting the operator (empty = none)
bool cancelable = false; // a procedure is running and Esc cancels it
// Heading setup runs in the strip: the TUI then takes the arrows for aiming,
// Tab for the step size, Enter to mark and f to fit.
bool heading_setup = false;
std::vector<std::string> detail; // extra lines under the live one (the marks)
};
// Last calibration fit, per axis, for the gimbal expanded view.
@ -340,6 +344,7 @@ struct UiSnapshot {
CaptureView capture;
ConnView conn;
std::vector<LogLine> log;
std::string log_file; // the file the log is being written to ("" = none)
DumpView dump;
ImuView imu;
EnvView env;

View File

@ -216,6 +216,7 @@ int main(int argc, char* argv[]) {
if (vm.count("log-level")) opts.log_level = vm["log-level"].as<std::string>();
if (vm.count("trace")) opts.trace_categories = vm["trace"].as<std::string>();
opts.config_path = *cfg_path; // so the setup commands can save back to it
if (file_log.isOpen()) opts.log_file = [&file_log] { return file_log.path(); };
if (vm.count("test")) {
opts.run_test = vm["test"].as<std::string>();
opts.use_tui = false; // a script wants lines on stdout, not a dashboard

View File

@ -282,6 +282,7 @@ struct Application::Impl {
std::atomic<bool> imu_pitch_warned_{false};
// The TUI heading wizard is aiming by hand; blocks a scan from starting.
bool heading_setup_active_ = false;
std::vector<HeadingSighting> heading_marks_; // `gimbal heading mark`s this setup
std::atomic<bool> running{true};
RemoteCommandRunner remote_; // commands over MQTT (GGS/FWT/<tower>/Command)
@ -591,7 +592,7 @@ struct Application::Impl {
void imuConfig() {
LOG_INFO << "re-reading IMU configuration (briefly pauses the stream)...";
imu->refreshConfig();
imu_config_done_ = false; // let the 'i' view pick the new values up too
imu_config_done_ = false; // let the sensors view pick the new values up too
auto c = imu->config();
if (!c) {
LOG_WARN << "IMU reported no configuration (older firmware may not answer the queries)";
@ -688,6 +689,7 @@ struct Application::Impl {
std::chrono::steady_clock::now() - start_time).count();
s.header.live = !mock_serial;
s.header.mode = scheduler ? modeName(scheduler->mode()) : "manual";
s.log_file = opts.log_file ? opts.log_file() : "";
// --- Gimbal ---
MotorTelemetry t = motor->telemetry();
@ -948,7 +950,19 @@ struct Application::Impl {
void fillActivity(UiSnapshot& s) const {
ActivityView& a = s.activity;
if (tester && tester->running()) {
if (heading_setup_active_) {
a.active = true;
a.heading_setup = true;
a.title = "HEADING SETUP";
a.status = "at " + std::to_string(s.gimbal.yaw.xenc) + " counts (reads " +
formatDegrees(s.gimbal.yaw.deg) + ")";
std::string marks;
for (const auto& m : heading_marks_)
marks += (marks.empty() ? "" : " ") + std::to_string(m.counts) + "\xE2\x86\x92" +
formatDegrees(m.heading_deg);
a.detail.push_back(marks.empty() ? "no marks yet - two landmarks minimum"
: "marks: " + marks);
} else if (tester && tester->running()) {
TestProgress p = tester->progress();
a.active = true;
a.title = "TESTING";
@ -997,7 +1011,7 @@ struct Application::Impl {
}
// A running procedure is cancelable with Esc.
a.cancelable = procedureBusy();
a.cancelable = procedureBusy() || heading_setup_active_;
// Persisted result: the most recent of last test / calibration / diagnostics.
const long long now = nowEpochMs();
@ -1657,7 +1671,7 @@ struct Application::Impl {
: ": reset FAILED (see above)");
}
// The one way the mode changes - `mode ...`, the s / x keys, --start, and a
// The one way the mode changes - `mode ...`, the a / p / m keys, --start, and a
// test pausing the scan all come here. Returns false, having said why and
// leaving the mode as it was, when the gimbal cannot do what was asked.
// StatusCode is published on every change, so it always says what the tower
@ -1766,30 +1780,55 @@ struct Application::Impl {
// `gimbal heading fit <counts>:<deg> ...` / `gimbal heading offset <deg>`.
//
// `fit` is what the TUI wizard emits after the operator has sighted landmarks,
// but it is a first-class command in its own right: on a headless tower an
// operator who already knows two (encoder position, true bearing) pairs can
// type it directly.
// Heading setup is four commands, so it works the same from the dashboard,
// the console and MQTT: `setup` claims the gimbal (no scan can start under
// it), `mark <bearing>` records where the gimbal points now against the true
// bearing of the landmark it is aimed at, `fit` fits the marks, `cancel`
// drops them. `fit <counts>:<deg> ...` skips the marking, for an operator who
// already knows the pairs.
void handleHeading(const std::vector<std::string>& tok) {
const std::string what = tok.size() > 2 ? lowerStr(tok[2]) : "";
if (what == "setup") {
// The TUI drives the interaction (arrow keys and typed bearings exist
// only there); this just claims the gimbal so a scan cannot start
// underneath it and move the axis between a sighting and its bearing.
if (procedureBusy()) {
LOG_WARN << "gimbal heading setup: " << procedureName() << " in progress";
return;
}
if (!requireManual("gimbal heading setup")) return;
heading_setup_active_ = true;
LOG_INFO << "heading setup: aim at a landmark whose true bearing you know, "
"Space to record an image, Enter to enter its bearing. Two "
"landmarks minimum, further apart is better.";
heading_marks_.clear();
LOG_INFO << "heading setup: aim at a landmark whose true bearing you know, then "
"'gimbal heading mark <bearing>'. Two landmarks minimum, further "
"apart is better; 'gimbal heading fit' when done.";
return;
}
if (what == "cancel") {
if (heading_setup_active_) LOG_INFO << "heading setup cancelled";
heading_setup_active_ = false;
heading_marks_.clear();
return;
}
if (what == "mark") {
if (!heading_setup_active_) {
LOG_WARN << "gimbal heading mark: no heading setup running "
"('gimbal heading setup' first)";
return;
}
double bearing = 0;
size_t used = 0;
try {
if (tok.size() != 4) throw std::invalid_argument("count");
bearing = std::stod(tok[3], &used);
if (used != tok[3].size() || !std::isfinite(bearing))
throw std::invalid_argument("trailing");
} catch (...) {
LOG_WARN << "usage: gimbal heading mark <true bearing of the landmark, deg>";
return;
}
// Where the gimbal points now: the operator aimed, then typed.
const long counts = motor->telemetry().yaw.xenc;
heading_marks_.push_back({counts, wrap360(bearing)});
LOG_INFO << "heading mark " << heading_marks_.size() << ": " << counts
<< " counts -> " << wrap360(bearing) << " deg true";
return;
}
if (what == "offset") {
@ -1808,16 +1847,31 @@ struct Application::Impl {
return;
}
if (what != "fit") {
LOG_WARN << "usage: gimbal heading <setup|fit <counts>:<deg> ...|offset <deg>>";
LOG_WARN << "usage: gimbal heading <setup|mark <deg>|fit [<counts>:<deg> ...]|"
"cancel|offset <deg>>";
return;
}
std::vector<HeadingSighting> sightings;
if (tok.size() == 3) { // bare `fit`: the marks of this setup
if (!heading_setup_active_) {
LOG_WARN << "usage: gimbal heading fit <counts>:<deg> <counts>:<deg> [...] "
"(or mark landmarks in 'gimbal heading setup' first)";
return;
}
if (heading_marks_.size() < 2) {
LOG_WARN << "gimbal heading fit: " << heading_marks_.size()
<< " mark(s); at least two landmarks are needed";
return;
}
sightings = heading_marks_;
} else {
std::string err;
if (!parseHeadingSightings({tok.begin() + 3, tok.end()}, sightings, err)) {
LOG_WARN << "gimbal heading fit: " << err;
return;
}
}
const HeadingFitResult r = fitHeading(sightings, cfg.geometry.yaw.center_count);
if (!r.ok) {
LOG_WARN << "gimbal heading fit: " << r.error;
@ -1838,6 +1892,7 @@ struct Application::Impl {
calib_save_source_ = heading_setup_active_ ? "gimbal heading setup"
: "gimbal heading fit";
heading_setup_active_ = false;
heading_marks_.clear();
// Offer to persist, through the same activity-strip prompt the pitch setup
// uses - the operator answers y/n without leaving the dashboard.
if (!opts.config_path.empty()) calib_save_pending_ = true;
@ -1882,7 +1937,7 @@ struct Application::Impl {
bool requireManual(const char* what) {
if (manualMode()) return true;
LOG_WARN << "ignored '" << what << "': the tower is in " << modeName(scheduler->mode())
<< " mode (press x / type 'mode manual' first)";
<< " mode (press m / type 'mode manual' first)";
return false;
}
@ -2200,7 +2255,13 @@ struct Application::Impl {
motor->sendCommand("STOP ALL");
break;
case Procedure::None:
if (heading_setup_active_) {
LOG_INFO << "heading setup cancelled";
heading_setup_active_ = false;
heading_marks_.clear();
} else {
LOG_INFO << "nothing to cancel";
}
break;
}
}
@ -2506,7 +2567,7 @@ struct Application::Impl {
// long op, or a DUMP over the same serial link). Ignored while busy.
static bool interferingVerb(const std::string& verb) {
return verb == "gimbal" || verb == "mode" || verb == "test" ||
verb == "refresh" || verb == "snapshot" || verb == "camera";
verb == "snapshot" || verb == "camera";
}
void handleCommand(const std::string& line) {
@ -2515,8 +2576,8 @@ struct Application::Impl {
LOG_TRACE_CAT(LogCat::Control) << "cmd " << line;
// Lock-out: while a test/calibration/homing runs, ignore interfering
// commands (Esc, `cancel`, or `mode manual` - the x key - cancels the
// procedure instead: procedures run in manual, so that is what x means).
// commands (Esc, `cancel`, or `mode manual` - the m key - cancels the
// procedure instead: procedures run in manual, so that is what m means).
if (procedureBusy() && c.verb == "mode" && c.device == "manual") {
cancelActiveProcedure();
return;
@ -2569,16 +2630,6 @@ struct Application::Impl {
bool on = Logger::level() != LogLevel::Debug;
Logger::setLevel(on ? LogLevel::Debug : LogLevel::Info);
LOG_INFO << "debug logging " << (on ? "on" : "off");
} else if (c.verb == "refresh") {
// Re-read live device state surfaced in the expanded views: the IMU
// configuration (e.g. after the XKF profile was changed externally)
// and the firmware register dump shown in the gimbal 'g' view.
LOG_INFO << "refresh: re-reading IMU configuration + firmware dump";
if (imu) {
imu->refreshConfig();
imu_config_done_ = false; // force buildSnapshot to re-read config()
}
motor->sendCommand("DUMP");
} else if (c.verb == "trace") {
handleTrace(c);
} else if (c.verb == "set") {

View File

@ -102,7 +102,10 @@ bool FileLogSink::openFile(const std::string& path) {
fail("cannot open " + path + ": " + std::strerror(errno));
return false;
}
{
std::lock_guard<std::mutex> lock(path_mutex_);
path_ = path;
}
bytes_ = 0;
lines_ = 0;
std::error_code ec;
@ -198,6 +201,7 @@ void FileLogSink::close() {
std::fclose(fp_);
fp_ = nullptr;
}
std::lock_guard<std::mutex> lock(path_mutex_);
path_.clear();
bytes_ = 0;
}

View File

@ -29,7 +29,7 @@ const std::vector<HelpSection>& helpCatalog() {
{"mode",
"Say which mode the tower is in (and, in pan, where it is looking).", {}},
{"mode auto",
"Sweep the scan grid, a picture at every waypoint. Key: s.", {
"Sweep the scan grid, a picture at every waypoint. Key: a.", {
"The normal, unattended state; the boot service starts here (--start).",
"In auto and pan only reading commands, logging, 'mode', 'cancel' and",
"'exit' are accepted: nothing that moves the gimbal or changes a setting."}},
@ -38,9 +38,10 @@ const std::vector<HelpSection>& helpCatalog() {
"Left to right across heading +/- span/2, one picture every pan_step_deg",
"(hardware.ini) or less, both edges included; then back to the left edge.",
"A span under one picture is the one heading. Pitch defaults to 0 (level).",
"Example: mode pan 90 30 (75, 81, ..., 105: six pictures)"}},
"Example: mode pan 90 30 (75, 81, ..., 105: six pictures). Key p pans",
"30 deg around where the gimbal points now."}},
{"mode manual",
"Stop capturing and take the gimbal by hand. Key: x.", {
"Stop capturing and take the gimbal by hand. Key: m.", {
"Every command is accepted. With a test, homing or calibration running,",
"cancels it instead (as Esc and 'cancel' do)."}},
}},
@ -68,18 +69,23 @@ const std::vector<HelpSection>& helpCatalog() {
"step moves the motor to a position it cannot reproduce.",
"Example: gimbal jog yaw -25"}},
{"gimbal heading setup",
"Tie encoder counts to true north by sighting known landmarks (TUI).", {
"Opens a guided pane (dashboard key 'H'): aim with the arrow keys, Tab",
"cycles coarse/fine/very-fine steps, Space records an image, Enter enters",
"that landmark's true bearing. Two landmarks minimum - the further apart",
"the better. Needs no IMU, which is the point: IMU yaw drifts and not",
"every tower has one."}},
{"gimbal heading fit <counts>:<deg> <counts>:<deg> [...]",
"Fit the yaw map directly from known (encoder position, bearing) pairs.", {
"What the 'H' wizard emits, usable by hand on a headless tower. Two pairs",
"fit exactly; more are least-squares fitted and report R^2. Pairs closer",
"than 10 deg apart are refused - the fit would look perfect and be wrong.",
"Tie encoder counts to true north by sighting known landmarks.", {
"Aim at a landmark, then 'gimbal heading mark <its true bearing>'; repeat,",
"then 'gimbal heading fit'. Two landmarks minimum, the further apart the",
"better. On the dashboard it runs in the activity strip: arrows aim, Tab",
"step size, Enter mark, Space image, f fit, Esc cancel. Needs no IMU."}},
{"gimbal heading mark <deg>",
"Record where the gimbal points now as the landmark's true bearing.", {
"Only during 'gimbal heading setup'. Example: gimbal heading mark 137.4"}},
{"gimbal heading fit [<counts>:<deg> ...]",
"Fit the yaw map from the marks, or from (encoder position, bearing) pairs.", {
"Bare, it fits this heading setup's marks. With pairs, it needs no setup -",
"for a headless tower whose pairs are known. Two fit exactly; more are",
"least-squares fitted and report R^2. Pairs closer than 10 deg apart are",
"refused - the fit would look perfect and be wrong.",
"Example: gimbal heading fit 480000:45.0 520000:115.0"}},
{"gimbal heading cancel",
"Stop heading setup and drop its marks (so do 'cancel' and Esc).", {}},
{"gimbal heading offset <deg>",
"Shift the whole yaw map by a known amount and save it.", {
"For when the tower is found to be off by a fixed bearing and re-sighting",
@ -107,7 +113,7 @@ const std::vector<HelpSection>& helpCatalog() {
{"imu status",
"Print the latest orientation reading.", {
"Yaw/pitch/roll, device temperature and sample counter. The Sensors panel",
"and the 'i' view show the same data continuously in the TUI."}},
"and the 's' view show the same data continuously in the TUI."}},
{"imu config",
"Re-read the device configuration and print it.", {
"Identity, firmware, sample rate, active XKF profile and the available",
@ -194,44 +200,38 @@ const std::vector<HelpSection>& helpCatalog() {
"Example: trace serial on / trace off"}},
}},
{"Display", "Terminal dashboard keys (TUI only).", {
{"? / g / i / c / m",
"Toggle the Help, Gimbal, Sensors, Camera and Comms overlays.", {
"An overlay takes the full terminal height. Esc closes it; with none open,",
"Esc cancels a running procedure. 'm' shows broker state, per-topic traffic",
"and the recent MQTT messages, including any dropped while offline."}},
{"a / p / m",
"Auto mode / pan around where the gimbal points now / manual mode.", {
"p is 'mode pan <current heading> 30 <current pitch>'. m also cancels a",
"running test, homing or calibration, as Esc does."}},
{"? / g / s / c / n",
"Toggle the Help, Gimbal, Sensors, Cameras and Connectivity views.", {
"A view takes the full terminal height; press its key again, or Esc, to",
"close it. With none open, Esc cancels a running procedure. 'n' shows",
"broker state, per-topic traffic and the recent MQTT messages."}},
{"Arrows",
"Nudge the gimbal: left/right yaw 5%, up/down pitch 10% (manual mode).", {
"In every view. During heading setup they aim in the step Tab selects."}},
{"PageUp/PageDown",
"Scroll the log, or the open view. The only scroll keys.", {
"The log and the message history jump back to the newest line when one",
"arrives; pause (f) first to read further back. In Help, j/k change",
"section."}},
{"Space",
"Take one image now and publish its CamEvent (manual mode only).", {
"Goes through the same settle-and-trigger path a scan waypoint uses, so",
"the file and the CamEvent are indistinguishable from a scan frame.",
"Refused in auto and pan."}},
{"H",
"Open the heading-setup wizard (see 'gimbal heading setup').", {
"Manual mode only. Blocks a scan from starting until it is finished or",
"cancelled with Esc."}},
{"Up/Down, j/k",
"Scroll the open overlay one line (Help: change section).", {
"The scrollbar on the right shows how much is off-screen."}},
{"PageUp/PageDown",
"Scroll the open overlay a screenful; on the dashboard, the log.", {
"Paging the log freezes it: new lines keep arriving but the view holds",
"still, and the LOG title shows 'scrollback'."}},
{"Home/End",
"Jump to the top/bottom; on the log, End resumes following the tail.", {}},
"the file and the CamEvent are indistinguishable from a scan frame."}},
{"f",
"Freeze the screen: to select and copy text, or to scroll back.", {
"While frozen nothing new is drawn, so the log stays where you page it.",
"The header says when it froze. f again resumes."}},
{"q",
"Ask whether to quit or leave the dashboard running in the background.", {
"Quitting stops capture and closes the camera; backgrounding detaches the",
"tmux client (same as Ctrl-b d) and leaves everything running. Ctrl-C still",
"shuts down immediately."}},
{"p",
"Freeze the 10 Hz repaint so terminal text selection survives.", {
"Any keypress repaints, so scrolling while paused drops the selection -",
"pause is for copying, not for reading scrollback."}},
}},
{"Session", "Help and exit.", {
{"refresh",
"Re-read live device state (TUI: press 'r').", {
"Re-queries the IMU configuration and requests a firmware dump, updating",
"the 'i' and 'g' views. Briefly pauses the IMU stream."}},
{"help [topic]",
"Show this reference; 'help <topic>' expands one section.", {
"Example: help positioning / help imu"}},

View File

@ -106,7 +106,7 @@ bool allowedIn(Mode mode, const std::string& line) {
const std::string& verb = w[0];
const std::string sub = w.size() > 1 ? w[1] : "";
if (verb == "help" || verb == "mode" || verb == "cancel" || verb == "exit" ||
verb == "debug" || verb == "trace" || verb == "refresh")
verb == "debug" || verb == "trace")
return true;
if (verb == "gimbal") return sub == "status" || sub == "dump";
if (verb == "imu") return sub == "status" || sub == "config";

View File

@ -15,6 +15,7 @@
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <cstdio>
#include <map>
#include <sstream>
@ -99,7 +100,11 @@ Decorator reflectHeight(int& out) {
struct ScrollState {
int top = 0; // first body LINE we want visible
bool follow = false; // log only: pin to the newest line instead
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.
@ -111,6 +116,17 @@ struct ScrollState {
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 -
@ -129,15 +145,14 @@ Element scrollableBody(Element body, ScrollState& st) {
// 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, bool arrows_scroll = true) {
Color c = Color::Cyan) {
std::string hint = keys;
if (st.scrollable())
hint += arrows_scroll ? " \xE2\x86\x91\xE2\x86\x93/PgUp/PgDn:scroll" : " PgUp/PgDn:scroll";
if (st.scrollable()) hint += " PgUp/PgDn:scroll";
return text(" " + name + " (" + hint + ") ") | bold | color(c);
}
Element cameraDetailTitle(const ScrollState& st) {
return overlayTitle("CAMERA SYSTEM", "c/Esc:close", st);
return overlayTitle("CAMERAS", "c/Esc:close", st);
}
// Fixed-decimal number. Bare std::to_string on a double gives six decimals, so
@ -162,18 +177,15 @@ Element keyHint(const std::string& key, const std::string& 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 };
enum class Overlay { None, Help, Gimbal, Sensors, Cameras, Conn };
// ---- heading-setup wizard (Overlay::Heading) -------------------------------
// ---- 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. Deliberately does NOT need an IMU: not every tower
// has one, and IMU yaw drifts too much to be a heading reference.
//
// The interaction lives here because arrow keys and typed input exist only in the
// TUI; the fit itself is `gimbal heading fit`, an ordinary command, so it stays
// testable and usable on a headless tower.
// 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 };
@ -204,19 +216,6 @@ long jogStepCounts(JogPhase p, long travel_counts) {
return step > kVeryFineCounts ? step : kVeryFineCounts;
}
struct Sighting {
long counts = 0;
double bearing = 0.0;
};
struct HeadingWizard {
JogPhase phase = JogPhase::Coarse;
bool entering = false; // typing a bearing for pending_counts
long pending_counts = 0;
std::vector<Sighting> sightings;
std::string note; // last action / error, shown in the pane
};
// 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) {
@ -227,131 +226,62 @@ std::string jogStepLabel(JogPhase p, long travel_counts, double counts_per_deg)
return out;
}
Element headingWizardPanel(const UiSnapshot& s, const HeadingWizard& w, Component bearing_input) {
const AxisView& y = s.gimbal.yaw;
const long travel = y.has_limits ? std::labs(y.lim_pos - y.lim_neg) : 0;
Elements rows;
rows.push_back(text("Aim at a landmark whose true bearing you know, then record it.") | bold);
rows.push_back(text("Two landmarks minimum; the further apart, the better the fit.") | dim);
rows.push_back(separator());
// Live position, in both the count the fit will use and the bearing the
// current (possibly wrong) calibration believes.
rows.push_back(hbox({text("position ") | dim,
text(std::to_string(y.xenc) + " counts") | bold,
text(" currently reads " + num(y.deg, 2) + " deg") | dim}));
rows.push_back(hbox({text("step ") | dim,
text(jogPhaseName(w.phase)) | bold | color(Color::Cyan),
text(" " + jogStepLabel(w.phase, travel, y.counts_per_deg)) | dim}));
if (!y.has_limits)
rows.push_back(text(" no homed travel yet - steps fall back to the finest; "
"home or press d in the gimbal view") | color(Color::Yellow));
rows.push_back(separator());
if (w.sightings.empty()) {
rows.push_back(text("no sightings recorded yet") | dim);
} else {
rows.push_back(text("sightings") | bold);
for (size_t i = 0; i < w.sightings.size(); ++i)
rows.push_back(text(" " + std::to_string(i + 1) + ". " +
std::to_string(w.sightings[i].counts) + " counts -> " +
num(w.sightings[i].bearing, 2) + " deg true"));
}
rows.push_back(separator());
if (w.entering) {
rows.push_back(hbox({text("bearing of the landmark at " + std::to_string(w.pending_counts) +
" counts (deg true): "),
bearing_input->Render() | flex}));
rows.push_back(text("Enter to record, Esc to discard this sighting") | dim);
} else if (s.capture.snapshot_armed) {
rows.push_back(text("snapshot armed - waiting for the gimbal to settle") |
color(Color::Yellow));
} else if (s.capture.has_last) {
// The operator identifies the landmark from the image, which reaches them
// through the ground station; the path is here so they can find the file.
rows.push_back(hbox({text("last image ") | dim,
text(s.capture.config.output_dir + "/" + s.capture.last_label + "/" +
std::to_string(s.capture.last_ts_ms) + ".jxl")}));
}
if (!w.note.empty()) rows.push_back(text(w.note) | color(Color::Yellow));
rows.push_back(separator());
rows.push_back(text(w.sightings.size() >= 2
? "f to fit and apply"
: "record at least two sightings before fitting") |
(w.sightings.size() >= 2 ? color(Color::Green) : dim));
return vbox(std::move(rows)) | flex;
}
// The bottom bar, contextual: the overview advertises everyday operations and
// the overlay keys, and each overlay advertises what its own body responds to.
// the view keys, and each view advertises what its own body responds to.
//
// This changes only what is ADVERTISED. Every binding still works everywhere -
// 's' switches to auto from inside the gimbal view exactly as before - so the bar
// '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) {
static const char* kArrowsV = "\xE2\x86\x91\xE2\x86\x93";
Element keyBar(Overlay overlay, bool manual, bool heading_setup) {
static const char* kArrows4 = "\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93";
// Auto/manual are wanted from every view, so they lead everywhere.
Elements e{keyHint("s", "Auto"), keyHint("x", "Manual")};
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(kArrows4, "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("r", "Refresh"));
e.push_back(keyHint("g i c m", "Views"));
if (manual) e.push_back(keyHint("H", "Heading"));
e.push_back(keyHint("p", "Pause"));
// Hand aiming is refused while a scan owns the axes, so stop
// advertising it rather than inviting a keypress that only warns.
if (manual) {
e.push_back(keyHint(kArrows4, "Nudge"));
e.push_back(keyHint("Spc", "Snap"));
}
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::Help:
e.push_back(keyHint(kArrowsV, "Section"));
e.push_back(keyHint("j/k", "Section"));
e.push_back(keyHint("PgUp/Dn", "Scroll"));
break;
case Overlay::Gimbal:
e.push_back(keyHint("d", "Dump"));
e.push_back(keyHint(kArrowsV, "Scroll"));
e.push_back(keyHint("PgUp/Dn", "Page"));
e.push_back(keyHint("Home/End", "Ends"));
e.push_back(keyHint("PgUp/Dn", "Scroll"));
break;
case Overlay::Sensors:
e.push_back(keyHint("r", "Refresh"));
e.push_back(keyHint(kArrowsV, "Scroll"));
e.push_back(keyHint("PgUp/Dn", "Page"));
e.push_back(keyHint("Home/End", "Ends"));
break;
case Overlay::Cameras:
case Overlay::Conn:
e.push_back(keyHint(kArrowsV, "Scroll"));
e.push_back(keyHint("PgUp/Dn", "Page"));
e.push_back(keyHint("Home/End", "Ends"));
break;
case Overlay::Heading:
// Replaces the common set entirely: start/stop are refused during
// heading setup, so advertising them here would only mislead.
e.clear();
e.push_back(keyHint(kArrows4, "Aim"));
e.push_back(keyHint("Tab", "Step size"));
e.push_back(keyHint("Spc", "Snap"));
e.push_back(keyHint("Enter", "Record"));
e.push_back(keyHint("f", "Fit"));
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(kArrows4, "Nudge"));
e.push_back(keyHint("Spc", "Snap"));
}
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"));
@ -432,13 +362,13 @@ Element sensorsPanel(const SensorsView& s) {
}
return vbox(std::move(rows));
};
// The title already says "[i]"; repeating "i to expand" in the body was noise.
// 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("");
return panel("[i] SENSORS", Color::Magenta,
return panel("[s] SENSORS", Color::Magenta,
vbox({group(s.imu, std::move(imu_badge)), separator(),
group(s.env, text(""))}));
}
@ -481,7 +411,7 @@ Element cameraPanel(const CaptureView& c) {
} else {
rows.push_back(labelRow("last", text("\xE2\x80\x94") | dim));
}
return panel("[c] CAMERA", Color::Blue, vbox(std::move(rows)));
return panel("[c] CAMERAS", Color::Blue, vbox(std::move(rows)));
}
// Expanded camera view ('c'): the full camera system — identity, live sensor
@ -629,7 +559,7 @@ 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("[m] COMMS", Color::Green, vbox({
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(),
@ -637,7 +567,7 @@ Element connPanel(const ConnView& v) {
}));
}
// Expanded comms view ('m'): broker state, the four topics with their last
// 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
@ -731,11 +661,18 @@ Element connDetailPanel(const ConnView& v, ScrollState& st) {
}
}
return window(overlayTitle("COMMS", "m/Esc:close", st, Color::Green),
return window(overlayTitle("CONNECTIVITY", "n/Esc:close", st, Color::Green),
scrollableBody(vbox(std::move(rows)), st));
}
Element logPanel(const std::vector<LogLine>& lines, ScrollState& 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;
@ -749,27 +686,31 @@ Element logPanel(const std::vector<LogLine>& lines, ScrollState& st) {
rows.push_back(text(l.text) | color(c));
}
if (rows.empty()) rows.push_back(text("(no log output yet)") | dim);
// Frozen scrollback withholds new lines, which is invisible without a cue.
Element label = text(" LOG ") | bold | color(Color::GrayLight);
Element title = label;
if (!st.follow)
title = hbox({label, text("scrollback ") | color(Color::Yellow) | bold,
text("(End:follow) ") | dim});
else if (st.scrollable())
title = hbox({label, text("(PgUp:scrollback) ") | 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) {
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);
}
@ -819,13 +760,12 @@ Element helpPanel(int sel, const DumpView& dump, ScrollState& st) {
rows.push_back(hbox({text(" "), text(l) | color(Color::Green)}));
} else {
rows.push_back(hbox({text(" "),
text("(none captured yet - run 'dump')") | dim}));
text("(none captured yet - run 'gimbal dump')") | dim}));
}
}
rows.push_back(text(""));
}
return window(overlayTitle("HELP", "?:close Up/Down:section", st, Color::Cyan,
/*arrows_scroll=*/false),
return window(overlayTitle("HELP", "?:close j/k:section", st, Color::Cyan),
scrollableBody(vbox(std::move(rows)), st));
}
@ -1038,7 +978,7 @@ Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const Calib
vbox({header, separator(), hbox(std::move(cols)) | flex}));
}
// Full-screen Sensors view (toggled with 'i'): every MTi channel with units,
// 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.
@ -1225,7 +1165,7 @@ Element sensorsDetailPanel(const ImuView& v, const EnvView& e, ScrollState& st)
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", "i/Esc:close r:refresh config", st, Color::Magenta),
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}));
}
@ -1302,6 +1242,7 @@ void TuiUi::refreshLoop() {
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
int help_sel = 0;
@ -1310,55 +1251,73 @@ void TuiUi::uiLoop() {
// 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 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
// Heading-setup wizard. Its own text field, so the command line keeps its
// buffer and history semantics untouched.
HeadingWizard wiz;
std::string bearing_buffer;
long wiz_yaw_counts = 0; // live yaw position, mirrored for CatchEvent
long wiz_yaw_travel = 0;
long wiz_pitch_travel = 0;
// 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 until told otherwise
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;
auto input = Input(&cmd_buffer, "type a command, Enter to run, Esc to cancel");
auto bearing_input = Input(&bearing_buffer, "e.g. 137.4");
// Only one text field is ever live, so a Tab container (rather than a
// Vertical) keeps focus unambiguous: index 0 = command line, 1 = the wizard's
// bearing field. Renderer's child is fixed at construction, hence the
// container rather than swapping the Input.
int focused_input = 0;
auto inputs = Container::Tab({input, bearing_input}, &focused_input);
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(inputs, [&] {
UiSnapshot s = snapshot_ ? snapshot_() : UiSnapshot{};
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_;
}
// Evicting from the front of the ring buffer slides every line up under a
// frozen viewport, so a busy log would drag the view the operator parked.
// Absorb the shift; the clamp in scrollableBody handles running off the top.
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;
wiz_yaw_counts = s.gimbal.yaw.xenc;
wiz_yaw_travel = s.gimbal.yaw.has_limits ? std::labs(s.gimbal.yaw.lim_pos - s.gimbal.yaw.lim_neg) : 0;
wiz_pitch_travel =
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;
focused_input = wiz.entering ? 1 : 0;
gimbal_overlay_open_.store(overlay == Overlay::Gimbal); // refreshLoop polls a dump while open
std::string mode = s.header.live ? "LIVE" : "MOCK";
@ -1367,10 +1326,6 @@ void TuiUi::uiLoop() {
text(" " + s.header.tower + " ") | bold,
text("build " + s.header.build) | dim,
filler(),
// A frozen frame has to say that it is frozen, and for how long.
// Without the age it is indistinguishable from a hung program: the
// numbers simply stop, which during a multi-minute home reads as a
// crash. Costing 15 minutes of "is it stuck?" is how this got added.
// 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.
@ -1399,7 +1354,7 @@ void TuiUi::uiLoop() {
Element bottom = command_mode
? hbox({text(" : ") | inverted, input->Render() | flex}) | border
: exit_prompt ? exitPrompt(backgroundAvailable(std::getenv("TMUX")))
: keyBar(overlay, s.capture.manual);
: keyBar(overlay, s.capture.manual, heading_setup);
// An open overlay takes over the whole body (the dashboard panels are
// hidden) so it has the full height — otherwise tall content like the
@ -1419,38 +1374,38 @@ void TuiUi::uiLoop() {
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});
case Overlay::Heading:
return vbox({header, separator(),
headingWizardPanel(s, wiz, bearing_input) | 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, log_scroll) | flex};
if (s.activity.active || s.activity.has_result || !s.activity.prompt.empty())
col.push_back(activityPanel(s.activity));
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));
}
}
});
// Apply a scroll key to one pane. `arrows` is false where Up/Down already mean
// something else (help section nav; gimbal nudge on the dashboard), leaving
// PgUp/PgDn/Home/End as the only scroll keys there.
auto scrollKeys = [](ScrollState& st, const Event& e, bool arrows) {
// 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 (arrows && (e == Event::ArrowDown)) { st.top = scrollBy(st.top, 1, ch, vh); return true; }
if (arrows && (e == Event::ArrowUp)) { st.top = scrollBy(st.top, -1, ch, vh); return true; }
if (e == Event::PageDown) { st.top = scrollPage(st.top, 1, ch, vh); return true; }
if (e == Event::PageUp) { st.top = scrollPage(st.top, -1, ch, vh); return true; }
if (e == Event::Home) { st.top = 0; return true; }
if (e == Event::End) { st.top = scrollMax(ch, vh); return true; }
if (arrows && e.is_character()) {
if (e.character() == "j") { st.top = scrollBy(st.top, 1, ch, vh); return true; }
if (e.character() == "k") { st.top = scrollBy(st.top, -1, ch, vh); return true; }
if (st.follow) {
st.follow = false;
st.top = scrollMax(ch, vh);
}
return false;
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) {
@ -1458,11 +1413,13 @@ void TuiUi::uiLoop() {
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;
}
@ -1493,155 +1450,71 @@ void TuiUi::uiLoop() {
return true; // swallow everything else
}
// The heading wizard owns the keyboard while it is up, like the exit
// prompt: a stray key must not reach the dashboard behind it and move the
// gimbal between a sighting and the bearing being typed for it.
if (overlay == Overlay::Heading) {
// 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) {
if (!sink_) return;
const long step = jogStepCounts(wiz.phase, travel) * sign;
const long step = jogStepCounts(jog_phase, travel) * sign;
sink_(std::string("gimbal jog ") + axis + " " + std::to_string(step));
};
if (wiz.entering) {
if (e == Event::Return) {
try {
size_t used = 0;
const double bearing = std::stod(bearing_buffer, &used);
if (used != bearing_buffer.size()) throw std::invalid_argument("trailing");
wiz.sightings.push_back({wiz.pending_counts, bearing});
wiz.note = "recorded " + num(bearing, 2) + " deg at " +
std::to_string(wiz.pending_counts) + " counts";
wiz.entering = false;
bearing_buffer.clear();
} catch (const std::exception&) {
wiz.note = "'" + bearing_buffer + "' is not a bearing in degrees";
}
return true;
}
if (e == Event::Escape) {
wiz.entering = false;
bearing_buffer.clear();
wiz.note = "sighting discarded";
return true;
}
return false; // let the Input edit the buffer
}
if (e == Event::ArrowLeft) { jog("yaw", wiz_yaw_travel, -1); return true; }
if (e == Event::ArrowRight) { jog("yaw", wiz_yaw_travel, +1); return true; }
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.
if (e == Event::ArrowUp) { jog("pitch", wiz_pitch_travel, +1); return true; }
if (e == Event::ArrowDown) { jog("pitch", wiz_pitch_travel, -1); return true; }
if (e == Event::ArrowUp) { jog("pitch", pitch_travel, +1); return true; }
if (e == Event::ArrowDown) { jog("pitch", pitch_travel, -1); return true; }
if (e == Event::Tab) {
wiz.phase = wiz.phase == JogPhase::Coarse ? JogPhase::Fine
: wiz.phase == JogPhase::Fine ? JogPhase::VeryFine
jog_phase = jog_phase == JogPhase::Coarse ? JogPhase::Fine
: jog_phase == JogPhase::Fine ? JogPhase::VeryFine
: JogPhase::Coarse;
return true;
}
if (e == Event::Return) {
// Freeze the position now: the operator is about to look away to
// read a bearing off a map, and the field must belong to where the
// gimbal is pointing at this moment.
wiz.pending_counts = wiz_yaw_counts;
wiz.entering = true;
wiz.note.clear();
bearing_buffer.clear();
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;
wiz = {};
bearing_buffer.clear();
if (sink_) sink_("gimbal heading cancel");
if (e == Event::Escape && overlay == Overlay::None) {
sink_("gimbal heading cancel");
return true;
}
if (e.is_character()) {
const std::string& k = e.character();
if (k == " ") { if (sink_) sink_("snapshot"); return true; }
if (k == "f" || k == "F") {
if (wiz.sightings.size() < 2) {
wiz.note = "need at least two sightings before fitting";
return true;
}
std::string cmd = "gimbal heading fit";
for (const auto& sg : wiz.sightings)
cmd += " " + std::to_string(sg.counts) + ":" + num(sg.bearing, 4);
if (sink_) sink_(cmd);
overlay = Overlay::None;
wiz = {};
return true;
}
}
return true; // swallow everything else
if (e == Event::Character("f")) { sink_("gimbal heading fit"); return true; }
}
const int n = static_cast<int>(helpCatalog().size());
if (overlay == Overlay::Help) { // help pane navigation
// Selecting a different section replaces the body wholesale, so the
// old scroll position would be meaningless - start at the top.
if (e == Event::ArrowDown) { help_sel = (help_sel + 1) % n; help_scroll = {}; return true; }
if (e == Event::ArrowUp) { help_sel = (help_sel - 1 + n) % n; help_scroll = {}; return true; }
}
// Scroll the overlay that owns the body. Arrows are free here except in
// Help, where the two cases above already claimed them for section nav.
if (overlay != Overlay::None) {
const bool arrows = (overlay != Overlay::Help);
// Scroll the view that owns the body, or the log. PgUp/PgDn only.
switch (overlay) {
case Overlay::Cameras:
if (scrollKeys(cam_scroll, e, arrows)) return true;
break;
case Overlay::Conn:
if (scrollKeys(conn_scroll, e, arrows)) return true;
break;
case Overlay::Sensors:
if (scrollKeys(sensors_scroll, e, arrows)) return true;
break;
case Overlay::Help:
if (scrollKeys(help_scroll, e, arrows)) return true;
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, arrows);
const bool b = scrollKeys(gimbal_scroll[1], e, arrows);
const bool a = scrollKeys(gimbal_scroll[0], e);
const bool b = scrollKeys(gimbal_scroll[1], e);
if (a || b) return true;
break;
}
case Overlay::None: 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; }
}
// Log scrollback. The arrows belong to the gimbal here, so this is
// PgUp/PgDn/Home/End only; End resumes following the tail.
if (overlay == Overlay::None) {
if (e == Event::End) {
log_scroll.follow = true;
log_scroll.top = scrollMax(log_scroll.contentH(), log_scroll.viewH());
return true;
}
if (e == Event::PageUp || e == Event::PageDown || e == Event::Home) {
if (log_scroll.follow) {
// Entering scrollback: freeze where the tail currently sits, and
// start tracking evictions from this point.
log_scroll.follow = false;
log_scroll.top = scrollMax(log_scroll.contentH(), log_scroll.viewH());
std::lock_guard<std::mutex> lock(log_mutex_);
log_dropped_at_freeze = log_dropped_;
}
return scrollKeys(log_scroll, e, /*arrows=*/false);
}
}
// Arrow keys nudge the gimbal in steps (only when no overlay is open):
// Left/Right = yaw -/+5%, Up/Down = pitch -/+10% of travel. Only in
// manual mode: during a scan the app refuses these anyway, and a silent
// no-op beats a warning per keypress from someone leaning on an arrow.
if (overlay == Overlay::None && manual_mode && sink_) {
// 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.
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) { sink_("gimbal nudge pitch -10"); return true; }
@ -1654,11 +1527,11 @@ void TuiUi::uiLoop() {
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;
if (overlay == Overlay::Help) help_scroll = {};
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;
@ -1676,49 +1549,34 @@ void TuiUi::uiLoop() {
if (sink_) sink_("gimbal dump");
return true;
}
if (c == "i") {
overlay = (overlay == Overlay::Sensors) ? Overlay::None : Overlay::Sensors;
if (overlay == Overlay::Sensors) sensors_scroll = {};
return true;
}
if (c == "c") {
overlay = (overlay == Overlay::Cameras) ? Overlay::None : Overlay::Cameras;
if (overlay == Overlay::Cameras) cam_scroll = {};
return true;
}
if (c == "m") {
overlay = (overlay == Overlay::Conn) ? Overlay::None : Overlay::Conn;
if (overlay == Overlay::Conn) conn_scroll = {};
return true;
}
if (c == "p") { // freeze/unfreeze the live refresh so text can be selected
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 (overlay == Overlay::Help) { // vim-style section nav while help is open
if (overlay == Overlay::Help) { // section nav while help is open
if (c == "j") { help_sel = (help_sel + 1) % n; help_scroll = {}; return true; }
if (c == "k") { help_sel = (help_sel - 1 + n) % n; help_scroll = {}; return true; }
}
if (c == "q") { exit_prompt = true; return true; }
if (c == "s") { if (sink_) sink_("mode auto"); return true; }
if (c == "x") { if (sink_) sink_("mode manual"); 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; }
// Heading setup: only from the dashboard, and only in manual mode - the
// app refuses to arm it during a scan, so opening the pane would strand
// the operator in a wizard that cannot record anything.
if (c == "H") {
if (!manual_mode) return true;
overlay = Overlay::Heading;
wiz = {};
bearing_buffer.clear();
if (sink_) sink_("gimbal heading setup");
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 during a scan; the bar just stops advertising it.
// The app refuses it outside manual; the bar just stops advertising it.
if (c == " ") { if (sink_) sink_("snapshot"); return true; }
return false;
});

View File

@ -88,7 +88,7 @@ SensorsView pendingSensorsView() {
// temperature is deliberately NOT here — it is diagnostic detail shown only
// in the expanded view, so the sole temperature on the main window is the
// SHT41's ambient one below.
v.imu.title = "MTi (orientation)";
v.imu.title = "IMU (orientation)";
v.imu.status = "pending";
v.imu.fields.push_back({"Roll", "--.-", "\xC2\xB0", false});
v.imu.fields.push_back({"Pitch", "--.-", "\xC2\xB0", false});

View File

@ -99,7 +99,7 @@ TEST_CASE("auto and pan accept only what cannot move the gimbal or change a sett
for (Mode m : {Mode::Auto, Mode::Pan}) {
CAPTURE(modeName(m));
for (const char* ok : {"help", "help imu", "gimbal status", "gimbal dump", "imu status",
"imu config", "refresh", "debug", "trace serial on", "mode",
"imu config", "debug", "trace serial on", "mode",
"mode manual", "mode pan 90 30", "cancel", "exit", "GIMBAL DUMP"}) {
CAPTURE(ok);
CHECK(allowedIn(m, ok));