From 79225bc3cd915cda6f2208ac6173b85766303a71 Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Fri, 25 Sep 2026 18:57:25 +0200 Subject: [PATCH] Rework the dashboard keys: a/p/m for modes, n for connectivity, page keys to scroll, heading setup in the activity strip --- README.md | 38 +-- docs/configuration.md | 57 ++-- docs/known-issues.md | 2 +- docs/modules-reference.md | 6 +- docs/mqtt-api.md | 4 +- docs/test-command.md | 2 +- docs/test-roadmap.md | 2 +- include/fgc/Application.h | 4 + include/fgc/FileLogSink.h | 11 +- include/fgc/ui/UiSnapshot.h | 7 +- main.cpp | 1 + src/core/Application.cpp | 119 +++++-- src/core/FileLogSink.cpp | 6 +- src/core/HelpText.cpp | 86 ++--- src/core/OperatingMode.cpp | 2 +- src/ui/TuiUi.cpp | 616 ++++++++++++++--------------------- src/ui/UiSnapshot.cpp | 2 +- tests/test_operatingmode.cpp | 2 +- 18 files changed, 456 insertions(+), 511 deletions(-) diff --git a/README.md b/README.md index a8af117..7656d48 100644 --- a/README.md +++ b/README.md @@ -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 `. diff --git a/docs/configuration.md b/docs/configuration.md index 22c709e..6dfe516 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 ` | 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 diff --git a/docs/known-issues.md b/docs/known-issues.md index cb8bff3..6a7b1ae 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -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 diff --git a/docs/modules-reference.md b/docs/modules-reference.md index bb5fc75..fc0cd86 100644 --- a/docs/modules-reference.md +++ b/docs/modules-reference.md @@ -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 `` 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//…` 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 diff --git a/docs/mqtt-api.md b/docs/mqtt-api.md index db77a15..cb6660f 100644 --- a/docs/mqtt-api.md +++ b/docs/mqtt-api.md @@ -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` diff --git a/docs/test-command.md b/docs/test-command.md index 0762f6a..0f41cd8 100644 --- a/docs/test-command.md +++ b/docs/test-command.md @@ -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 diff --git a/docs/test-roadmap.md b/docs/test-roadmap.md index 3be7bb1..2aea8f7 100644 --- a/docs/test-roadmap.md +++ b/docs/test-roadmap.md @@ -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 diff --git a/include/fgc/Application.h b/include/fgc/Application.h index 0ceb851..05b44f9 100644 --- a/include/fgc/Application.h +++ b/include/fgc/Application.h @@ -3,6 +3,7 @@ #include "fgc/Config.h" #include +#include #include #include @@ -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 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, diff --git a/include/fgc/FileLogSink.h b/include/fgc/FileLogSink.h index 09950f1..2bdb7c6 100644 --- a/include/fgc/FileLogSink.h +++ b/include/fgc/FileLogSink.h @@ -3,6 +3,7 @@ #include "fgc/Logger.h" #include +#include #include #include @@ -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 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 "..log" suffix long long bytes_ = 0; long long lines_ = 0; // lines in the CURRENT file; guards rotation loops diff --git a/include/fgc/ui/UiSnapshot.h b/include/fgc/ui/UiSnapshot.h index 00a92f0..702574a 100644 --- a/include/fgc/ui/UiSnapshot.h +++ b/include/fgc/ui/UiSnapshot.h @@ -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 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 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 log; + std::string log_file; // the file the log is being written to ("" = none) DumpView dump; ImuView imu; EnvView env; diff --git a/main.cpp b/main.cpp index 8cc03d5..d47dd23 100644 --- a/main.cpp +++ b/main.cpp @@ -216,6 +216,7 @@ int main(int argc, char* argv[]) { if (vm.count("log-level")) opts.log_level = vm["log-level"].as(); if (vm.count("trace")) opts.trace_categories = vm["trace"].as(); 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(); opts.use_tui = false; // a script wants lines on stdout, not a dashboard diff --git a/src/core/Application.cpp b/src/core/Application.cpp index faa2551..e4b87f9 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -282,6 +282,7 @@ struct Application::Impl { std::atomic imu_pitch_warned_{false}; // The TUI heading wizard is aiming by hand; blocks a scan from starting. bool heading_setup_active_ = false; + std::vector heading_marks_; // `gimbal heading mark`s this setup std::atomic running{true}; RemoteCommandRunner remote_; // commands over MQTT (GGS/FWT//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 : ...` / `gimbal heading offset `. // - // `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 ` 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 : ...` skips the marking, for an operator who + // already knows the pairs. void handleHeading(const std::vector& 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 '. 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 "; + 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,15 +1847,30 @@ struct Application::Impl { return; } if (what != "fit") { - LOG_WARN << "usage: gimbal heading : ...|offset >"; + LOG_WARN << "usage: gimbal heading |fit [: ...]|" + "cancel|offset >"; return; } std::vector sightings; - std::string err; - if (!parseHeadingSightings({tok.begin() + 3, tok.end()}, sightings, err)) { - LOG_WARN << "gimbal heading fit: " << err; - return; + if (tok.size() == 3) { // bare `fit`: the marks of this setup + if (!heading_setup_active_) { + LOG_WARN << "usage: gimbal heading fit : : [...] " + "(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) { @@ -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: - LOG_INFO << "nothing to cancel"; + 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") { diff --git a/src/core/FileLogSink.cpp b/src/core/FileLogSink.cpp index de9824a..4ad0e61 100644 --- a/src/core/FileLogSink.cpp +++ b/src/core/FileLogSink.cpp @@ -102,7 +102,10 @@ bool FileLogSink::openFile(const std::string& path) { fail("cannot open " + path + ": " + std::strerror(errno)); return false; } - path_ = path; + { + std::lock_guard 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 lock(path_mutex_); path_.clear(); bytes_ = 0; } diff --git a/src/core/HelpText.cpp b/src/core/HelpText.cpp index b99f55a..ee0f8c0 100644 --- a/src/core/HelpText.cpp +++ b/src/core/HelpText.cpp @@ -29,7 +29,7 @@ const std::vector& 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& 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& 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 : : [...]", - "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 '; 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 ", + "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 [: ...]", + "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 ", "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& 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& 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 30 '. 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 ' expands one section.", { "Example: help positioning / help imu"}}, diff --git a/src/core/OperatingMode.cpp b/src/core/OperatingMode.cpp index 928185a..089e68a 100644 --- a/src/core/OperatingMode.cpp +++ b/src/core/OperatingMode.cpp @@ -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"; diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index 2ac5295..70a1f3c 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -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 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")}; - - 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("PgUp/Dn", "Log")); - break; - case Overlay::Help: - e.push_back(keyHint(kArrowsV, "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")); - 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")); + 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("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("j/k", "Section")); + e.push_back(keyHint("PgUp/Dn", "Scroll")); + break; + case Overlay::Gimbal: + e.push_back(keyHint("d", "Dump")); + e.push_back(keyHint("PgUp/Dn", "Scroll")); + break; + case Overlay::Sensors: + case Overlay::Cameras: + case Overlay::Conn: + e.push_back(keyHint("PgUp/Dn", "Scroll")); + break; + } + // Hand aiming is refused outside manual, so stop advertising it rather + // than inviting a keypress that only warns. + if (manual) { + e.push_back(keyHint(kArrows4, "Nudge")); e.push_back(keyHint("Spc", "Snap")); - e.push_back(keyHint("Enter", "Record")); - e.push_back(keyHint("f", "Fit")); - break; + } + if (overlay != Overlay::None) e.push_back(keyHint("Esc", "Close")); } - - 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& 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& lines, const std::string& file, ScrollState& st) { std::vector rows; for (const auto& l : lines) { Color c = Color::Default; @@ -749,27 +686,31 @@ Element logPanel(const std::vector& 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 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; @@ -1309,56 +1250,74 @@ void TuiUi::uiLoop() { bool calib_prompt = false; // a yes/no calib-save question is showing // Mirrored out of the snapshot each render: CatchEvent runs on the same // thread but has no access to the renderer's local snapshot copy. - bool manual_mode = true; + bool 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 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{}; - size_t log_dropped_now = 0; - { + 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 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(log_dropped_now - log_dropped_at_freeze); log_dropped_at_freeze = log_dropped_now; } - 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 = + const ChannelStats& cs = s.conn.stats; + followNew(conn_scroll, + static_cast(cs.published_ok + cs.published_dropped + + cs.published_failed + cs.received), + paused_); + const auto dump_stamp = std::hash{}(s.dump.text); + for (auto& g : gimbal_scroll) followNew(g, dump_stamp, paused_); + + calib_prompt = !s.activity.prompt.empty(); + manual_mode = s.capture.manual; + 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 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,123 +1450,59 @@ 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 - : JogPhase::Coarse; + 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(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(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); - 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; - 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); - if (a || b) return true; - break; - } - case Overlay::None: break; + // Scroll the view that owns the body, or the log. PgUp/PgDn only. + switch (overlay) { + case Overlay::None: if (scrollKeys(log_scroll, e)) { + std::lock_guard lock(log_mutex_); + log_dropped_at_freeze = log_dropped_; + return true; + } + break; + case Overlay::Cameras: if (scrollKeys(cam_scroll, e)) return true; break; + case Overlay::Conn: if (scrollKeys(conn_scroll, e)) return true; break; + case Overlay::Sensors: if (scrollKeys(sensors_scroll, e)) return true; break; + case Overlay::Help: if (scrollKeys(help_scroll, e)) return true; break; + case Overlay::Gimbal: { + // Both axis columns move together: they are read side by side, + // and each clamps against its own measured height. + const bool a = scrollKeys(gimbal_scroll[0], e); + const bool b = scrollKeys(gimbal_scroll[1], e); + if (a || b) return true; + break; } } if (overlay != Overlay::None && e == Event::Escape) { overlay = Overlay::None; return true; } @@ -1617,31 +1510,11 @@ void TuiUi::uiLoop() { 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 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; }); diff --git a/src/ui/UiSnapshot.cpp b/src/ui/UiSnapshot.cpp index 85e4a16..acea9cb 100644 --- a/src/ui/UiSnapshot.cpp +++ b/src/ui/UiSnapshot.cpp @@ -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}); diff --git a/tests/test_operatingmode.cpp b/tests/test_operatingmode.cpp index 876d464..fcb7ae3 100644 --- a/tests/test_operatingmode.cpp +++ b/tests/test_operatingmode.cpp @@ -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));