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 | | 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` | | **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 | | **SENSORS** `s` | the orientation sensor (IMU) (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 | | **CAMERAS** `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 | | **CONNECTIVITY** `n` | the broker connection, the tower name the messages carry, the mode as published |
| **LOG** | the latest log lines; warnings in yellow, errors in red. `PgUp` scrolls back, `End` follows again | | **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 homing, a test) and the result of the last one | | **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 | | **Key bar** | the keys that do something in this view |
### Keys ### Keys
| Key | Does | | 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 | | `h` | home the gimbal |
| `r` | refresh: ask the firmware for a full status dump, re-read the sensor setup | | `g` `s` `c` `n` | open the full-screen gimbal, sensors, cameras or connectivity view; press it again to close |
| `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 (`j`/`k` change section) |
| `?` | the built-in help: every command, with examples |
| `:` | type a command, exactly as in the [command list](#everyday-commands) | | `:` | 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) | | `f` | freeze the display, to select and copy text or to page back through the log (the program keeps running) |
| `PgUp` `PgDn` `Home` `End` | scroll the log | | `PgUp` `PgDn` | scroll the log, or the open view - the only scroll keys |
| arrows | nudge the gimbal by hand - only when no sweep is running | | arrows | nudge the gimbal by hand, from any view - manual mode only |
| `Space` | take one picture now - only when no sweep is running | | `Space` | take one picture now - manual mode only |
| `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, a heading setup) |
| `Esc` | close a view; with no view open, **cancel** whatever is running (a test, a homing, a calibration) |
| `q` | quit or background the program - it asks which | | `q` | quit or background the program - it asks which |
| `Ctrl-b d` | leave the dashboard and keep the program running (a `tmux` key) | | `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) ![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 rate, magnetic field, its internal temperature) and configuration, and the
ambient sensor with the bus it is on. ambient sensor with the bus it is on.
![The sensors view](docs/images/readme/sensors.png) ![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 temperature, frames delivered and dropped, stream restarts), its profile and
encoding, and the whole scan grid with the current waypoint marked. encoding, and the whole scan grid with the current waypoint marked.
![The camera view](docs/images/readme/camera.png) ![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 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. **Help** (`?`) - every command, grouped, with an explanation and an example.
The same text is available anywhere as `help` or `help <topic>`. 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 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. 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 | | Key | Action |
|---|---| |---|---|
@ -133,7 +144,7 @@ In the TUI press **`H`** (manual mode only):
| `↑` `↓` | Aim pitch (centre the landmark vertically) | | `↑` `↓` | Aim pitch (centre the landmark vertically) |
| `Tab` | Cycle step size: coarse (5% of travel) → fine (0.5%) → very fine (25 counts, ≈0.05°) | | `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 | | `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 | | `f` | Fit and apply |
| `Esc` | Cancel | | `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 filter-profile / **XKF profile** via `ReqFilterProfile` + `ReqAvailableFilterProfiles`). The decoded
values — product code, firmware, device ID, output mode/format, calibration channels, sample rate, and 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 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 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 (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 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`). 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 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 stays stale until you re-read it with `imu config`, which re-queries the device (briefly pausing the
pausing the stream) and also requests a fresh firmware dump for the gimbal `g` view. stream).
#### The device configuration is verified, not assumed #### 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. instead of being dropped.
A failed check never stops the program: it logs at `ERROR`, shows a red **MISCONFIGURED** 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 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 matter because the handshake runs *before* the TUI installs its log sink, so the log line
alone is wiped by the alt-screen. 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 | | 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 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 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. 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), 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 **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 **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, 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, control mode, target heading). Adding a panel later is a struct in `UiSnapshot.h` plus 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). 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): **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 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 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. 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 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 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. subscriber. The compact Connectivity panel carries the `n` badge that opens it.
- **Sensors** (`i`) — both sensors, in headed sections so every reading is attributable. - **Sensors** (`s`) — both sensors, in headed sections so every reading is attributable.
**MTi**: every channel with units — orientation (°), acceleration (m/s²), rate-of-turn **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 (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, (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. (`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 **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 currently-running special operation with live progress (`test`, `gimbal pitch setup`, homing, the
scan) **and persists the last test/calibration result** (PASS/FAIL, age) so it doesn't scroll 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** away in the log. It only appears once something has run. While a procedure is running, **Esc**
cancels it. 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. `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 **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 — binding. `a` auto, `p` pan (30° around where the gimbal points now) and `m` manual lead everywhere —
and `:` command, `?` help and `q` quit close every set. The overview adds `h` home, `r` refresh, the mode can be changed from any view — and `:` command, `?` help and `q` quit close every set. The
`g i c m` views, `p` pause and the arrow keys (nudge the gimbal, yaw ±5 % / pitch ±10 %); an overview adds `h` home, `g s c n` views and `f` pause; an expanded view offers its own (`d` dump in
expanded view instead offers its own (`d` dump in the gimbal view, `r` refresh in sensors, the the gimbal view, `j`/`k` sections in help, `Esc` close). In manual mode the arrow keys nudge the gimbal
scroll keys, `Esc` close). Every binding still *works* everywhere; only the advertising changes. (yaw ±5 % / pitch ±10 %) from any view, and `Space` takes a picture. Every binding still *works*
`p` freezes the live refresh so text can be selected and copied. 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, **`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 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. wanted.
- Ambient temperature/humidity (SHT41, `IEnvSensor`/`Sht41EnvSensor`/`MockEnvSensor`) is fully - Ambient temperature/humidity (SHT41, `IEnvSensor`/`Sht41EnvSensor`/`MockEnvSensor`) is fully
integrated and **validated against the physical sensor on the LattePanda** — config-gated 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 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 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 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/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/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/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/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/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()` | | [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). 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 `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 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)) ### `Frame` ([ICameraSource.h](../include/fgc/ICameraSource.h))
Owned pixel buffer + `width`, `height`, `channels` (1 or 3), `timestamp_ms`, `cam_id`. 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** 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 (`scenario_type`/`version`, resolved to `scenario_label` against `available_profiles`). Built by
`applyImuConfigAck` (one ack frame at a time) + `finalizeImuConfig` (derive rate, resolve label). `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`). "filter profile" — same MIDs (`0x62/0x64/0x65`).
## On-disk artifacts ## 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** - 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 `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 | | 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 **What each mode accepts.** Manual accepts every command. Auto and pan accept
only what cannot move the gimbal or change a setting: 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` - logging: `debug`, `trace`
- `mode ...`, `cancel`, `exit` - `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 | | `test strict` | everything, `strict` profile |
While a test runs, interfering commands (moves, homing, a second test, capture, 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. `g`, `?`) keep working.
## Subsystems and leaves ## 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 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` / [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 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 ([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 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 "fgc/Config.h"
#include <memory> #include <memory>
#include <functional>
#include <optional> #include <optional>
#include <string> #include <string>
@ -28,6 +29,9 @@ struct RuntimeOptions {
std::string log_level; // empty => default std::string log_level; // empty => default
std::string trace_categories; // comma list (serial,mqtt,camera,control,all); empty => unset 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 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 // --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, // console, e.g. "hardware" or "hardware motor") once the motor link is up,

View File

@ -3,6 +3,7 @@
#include "fgc/Logger.h" #include "fgc/Logger.h"
#include <cstdio> #include <cstdio>
#include <mutex>
#include <string> #include <string>
#include <vector> #include <vector>
@ -67,7 +68,12 @@ public:
void close(); void close();
bool isOpen() const { return fp_ != nullptr; } 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 // 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. // rather than retrying every line. Poll this from outside Logger's lock.
bool failed() const { return failed_; } bool failed() const { return failed_; }
@ -86,7 +92,8 @@ private:
std::FILE* fp_ = nullptr; std::FILE* fp_ = nullptr;
FileLogPolicy policy_; FileLogPolicy policy_;
std::string header_; 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 std::string base_; // path without the ".<NNN>.log" suffix
long long bytes_ = 0; long long bytes_ = 0;
long long lines_ = 0; // lines in the CURRENT file; guards rotation loops 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 // A labelled group of readings from one physical sensor, rendered as its own
// subsection in the Sensors panel. // subsection in the Sensors panel.
struct SensorGroup { struct SensorGroup {
std::string title; // "MTi (orientation)" / "SHT41 (ambient)" std::string title; // "IMU (orientation)" / "SHT41 (ambient)"
std::string status = "pending"; // "live" / "pending" / "no fix" std::string status = "pending"; // "live" / "pending" / "no fix"
bool present = false; // true once the driver feeds real data bool present = false; // true once the driver feeds real data
std::vector<SensorField> fields; std::vector<SensorField> fields;
@ -279,6 +279,10 @@ struct ActivityView {
UiColor result_color = UiColor::Default; // green pass / red fail UiColor result_color = UiColor::Default; // green pass / red fail
std::string prompt; // a yes/no question awaiting the operator (empty = none) std::string prompt; // a yes/no question awaiting the operator (empty = none)
bool cancelable = false; // a procedure is running and Esc cancels it 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. // Last calibration fit, per axis, for the gimbal expanded view.
@ -340,6 +344,7 @@ struct UiSnapshot {
CaptureView capture; CaptureView capture;
ConnView conn; ConnView conn;
std::vector<LogLine> log; std::vector<LogLine> log;
std::string log_file; // the file the log is being written to ("" = none)
DumpView dump; DumpView dump;
ImuView imu; ImuView imu;
EnvView env; 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("log-level")) opts.log_level = vm["log-level"].as<std::string>();
if (vm.count("trace")) opts.trace_categories = vm["trace"].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 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")) { if (vm.count("test")) {
opts.run_test = vm["test"].as<std::string>(); opts.run_test = vm["test"].as<std::string>();
opts.use_tui = false; // a script wants lines on stdout, not a dashboard 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}; std::atomic<bool> imu_pitch_warned_{false};
// The TUI heading wizard is aiming by hand; blocks a scan from starting. // The TUI heading wizard is aiming by hand; blocks a scan from starting.
bool heading_setup_active_ = false; bool heading_setup_active_ = false;
std::vector<HeadingSighting> heading_marks_; // `gimbal heading mark`s this setup
std::atomic<bool> running{true}; std::atomic<bool> running{true};
RemoteCommandRunner remote_; // commands over MQTT (GGS/FWT/<tower>/Command) RemoteCommandRunner remote_; // commands over MQTT (GGS/FWT/<tower>/Command)
@ -591,7 +592,7 @@ struct Application::Impl {
void imuConfig() { void imuConfig() {
LOG_INFO << "re-reading IMU configuration (briefly pauses the stream)..."; LOG_INFO << "re-reading IMU configuration (briefly pauses the stream)...";
imu->refreshConfig(); 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(); auto c = imu->config();
if (!c) { if (!c) {
LOG_WARN << "IMU reported no configuration (older firmware may not answer the queries)"; 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(); std::chrono::steady_clock::now() - start_time).count();
s.header.live = !mock_serial; s.header.live = !mock_serial;
s.header.mode = scheduler ? modeName(scheduler->mode()) : "manual"; s.header.mode = scheduler ? modeName(scheduler->mode()) : "manual";
s.log_file = opts.log_file ? opts.log_file() : "";
// --- Gimbal --- // --- Gimbal ---
MotorTelemetry t = motor->telemetry(); MotorTelemetry t = motor->telemetry();
@ -948,7 +950,19 @@ struct Application::Impl {
void fillActivity(UiSnapshot& s) const { void fillActivity(UiSnapshot& s) const {
ActivityView& a = s.activity; 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(); TestProgress p = tester->progress();
a.active = true; a.active = true;
a.title = "TESTING"; a.title = "TESTING";
@ -997,7 +1011,7 @@ struct Application::Impl {
} }
// A running procedure is cancelable with Esc. // 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. // Persisted result: the most recent of last test / calibration / diagnostics.
const long long now = nowEpochMs(); const long long now = nowEpochMs();
@ -1657,7 +1671,7 @@ struct Application::Impl {
: ": reset FAILED (see above)"); : ": 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 // 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. // 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 // 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>`. // `gimbal heading fit <counts>:<deg> ...` / `gimbal heading offset <deg>`.
// //
// `fit` is what the TUI wizard emits after the operator has sighted landmarks, // Heading setup is four commands, so it works the same from the dashboard,
// but it is a first-class command in its own right: on a headless tower an // the console and MQTT: `setup` claims the gimbal (no scan can start under
// operator who already knows two (encoder position, true bearing) pairs can // it), `mark <bearing>` records where the gimbal points now against the true
// type it directly. // 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) { void handleHeading(const std::vector<std::string>& tok) {
const std::string what = tok.size() > 2 ? lowerStr(tok[2]) : ""; const std::string what = tok.size() > 2 ? lowerStr(tok[2]) : "";
if (what == "setup") { 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()) { if (procedureBusy()) {
LOG_WARN << "gimbal heading setup: " << procedureName() << " in progress"; LOG_WARN << "gimbal heading setup: " << procedureName() << " in progress";
return; return;
} }
if (!requireManual("gimbal heading setup")) return; if (!requireManual("gimbal heading setup")) return;
heading_setup_active_ = true; heading_setup_active_ = true;
LOG_INFO << "heading setup: aim at a landmark whose true bearing you know, " heading_marks_.clear();
"Space to record an image, Enter to enter its bearing. Two " LOG_INFO << "heading setup: aim at a landmark whose true bearing you know, then "
"landmarks minimum, further apart is better."; "'gimbal heading mark <bearing>'. Two landmarks minimum, further "
"apart is better; 'gimbal heading fit' when done.";
return; return;
} }
if (what == "cancel") { if (what == "cancel") {
if (heading_setup_active_) LOG_INFO << "heading setup cancelled"; if (heading_setup_active_) LOG_INFO << "heading setup cancelled";
heading_setup_active_ = false; 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; return;
} }
if (what == "offset") { if (what == "offset") {
@ -1808,16 +1847,31 @@ struct Application::Impl {
return; return;
} }
if (what != "fit") { 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; return;
} }
std::vector<HeadingSighting> sightings; 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; std::string err;
if (!parseHeadingSightings({tok.begin() + 3, tok.end()}, sightings, err)) { if (!parseHeadingSightings({tok.begin() + 3, tok.end()}, sightings, err)) {
LOG_WARN << "gimbal heading fit: " << err; LOG_WARN << "gimbal heading fit: " << err;
return; return;
} }
}
const HeadingFitResult r = fitHeading(sightings, cfg.geometry.yaw.center_count); const HeadingFitResult r = fitHeading(sightings, cfg.geometry.yaw.center_count);
if (!r.ok) { if (!r.ok) {
LOG_WARN << "gimbal heading fit: " << r.error; LOG_WARN << "gimbal heading fit: " << r.error;
@ -1838,6 +1892,7 @@ struct Application::Impl {
calib_save_source_ = heading_setup_active_ ? "gimbal heading setup" calib_save_source_ = heading_setup_active_ ? "gimbal heading setup"
: "gimbal heading fit"; : "gimbal heading fit";
heading_setup_active_ = false; heading_setup_active_ = false;
heading_marks_.clear();
// Offer to persist, through the same activity-strip prompt the pitch setup // Offer to persist, through the same activity-strip prompt the pitch setup
// uses - the operator answers y/n without leaving the dashboard. // uses - the operator answers y/n without leaving the dashboard.
if (!opts.config_path.empty()) calib_save_pending_ = true; if (!opts.config_path.empty()) calib_save_pending_ = true;
@ -1882,7 +1937,7 @@ struct Application::Impl {
bool requireManual(const char* what) { bool requireManual(const char* what) {
if (manualMode()) return true; if (manualMode()) return true;
LOG_WARN << "ignored '" << what << "': the tower is in " << modeName(scheduler->mode()) 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; return false;
} }
@ -2200,7 +2255,13 @@ struct Application::Impl {
motor->sendCommand("STOP ALL"); motor->sendCommand("STOP ALL");
break; break;
case Procedure::None: 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"; LOG_INFO << "nothing to cancel";
}
break; break;
} }
} }
@ -2506,7 +2567,7 @@ struct Application::Impl {
// long op, or a DUMP over the same serial link). Ignored while busy. // long op, or a DUMP over the same serial link). Ignored while busy.
static bool interferingVerb(const std::string& verb) { static bool interferingVerb(const std::string& verb) {
return verb == "gimbal" || verb == "mode" || verb == "test" || return verb == "gimbal" || verb == "mode" || verb == "test" ||
verb == "refresh" || verb == "snapshot" || verb == "camera"; verb == "snapshot" || verb == "camera";
} }
void handleCommand(const std::string& line) { void handleCommand(const std::string& line) {
@ -2515,8 +2576,8 @@ struct Application::Impl {
LOG_TRACE_CAT(LogCat::Control) << "cmd " << line; LOG_TRACE_CAT(LogCat::Control) << "cmd " << line;
// Lock-out: while a test/calibration/homing runs, ignore interfering // Lock-out: while a test/calibration/homing runs, ignore interfering
// commands (Esc, `cancel`, or `mode manual` - the x key - cancels the // commands (Esc, `cancel`, or `mode manual` - the m key - cancels the
// procedure instead: procedures run in manual, so that is what x means). // procedure instead: procedures run in manual, so that is what m means).
if (procedureBusy() && c.verb == "mode" && c.device == "manual") { if (procedureBusy() && c.verb == "mode" && c.device == "manual") {
cancelActiveProcedure(); cancelActiveProcedure();
return; return;
@ -2569,16 +2630,6 @@ struct Application::Impl {
bool on = Logger::level() != LogLevel::Debug; bool on = Logger::level() != LogLevel::Debug;
Logger::setLevel(on ? LogLevel::Debug : LogLevel::Info); Logger::setLevel(on ? LogLevel::Debug : LogLevel::Info);
LOG_INFO << "debug logging " << (on ? "on" : "off"); 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") { } else if (c.verb == "trace") {
handleTrace(c); handleTrace(c);
} else if (c.verb == "set") { } 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)); fail("cannot open " + path + ": " + std::strerror(errno));
return false; return false;
} }
{
std::lock_guard<std::mutex> lock(path_mutex_);
path_ = path; path_ = path;
}
bytes_ = 0; bytes_ = 0;
lines_ = 0; lines_ = 0;
std::error_code ec; std::error_code ec;
@ -198,6 +201,7 @@ void FileLogSink::close() {
std::fclose(fp_); std::fclose(fp_);
fp_ = nullptr; fp_ = nullptr;
} }
std::lock_guard<std::mutex> lock(path_mutex_);
path_.clear(); path_.clear();
bytes_ = 0; bytes_ = 0;
} }

View File

@ -29,7 +29,7 @@ const std::vector<HelpSection>& helpCatalog() {
{"mode", {"mode",
"Say which mode the tower is in (and, in pan, where it is looking).", {}}, "Say which mode the tower is in (and, in pan, where it is looking).", {}},
{"mode auto", {"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).", "The normal, unattended state; the boot service starts here (--start).",
"In auto and pan only reading commands, logging, 'mode', 'cancel' and", "In auto and pan only reading commands, logging, 'mode', 'cancel' and",
"'exit' are accepted: nothing that moves the gimbal or changes a setting."}}, "'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", "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.", "(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).", "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", {"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,", "Every command is accepted. With a test, homing or calibration running,",
"cancels it instead (as Esc and 'cancel' do)."}}, "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.", "step moves the motor to a position it cannot reproduce.",
"Example: gimbal jog yaw -25"}}, "Example: gimbal jog yaw -25"}},
{"gimbal heading setup", {"gimbal heading setup",
"Tie encoder counts to true north by sighting known landmarks (TUI).", { "Tie encoder counts to true north by sighting known landmarks.", {
"Opens a guided pane (dashboard key 'H'): aim with the arrow keys, Tab", "Aim at a landmark, then 'gimbal heading mark <its true bearing>'; repeat,",
"cycles coarse/fine/very-fine steps, Space records an image, Enter enters", "then 'gimbal heading fit'. Two landmarks minimum, the further apart the",
"that landmark's true bearing. Two landmarks minimum - the further apart", "better. On the dashboard it runs in the activity strip: arrows aim, Tab",
"the better. Needs no IMU, which is the point: IMU yaw drifts and not", "step size, Enter mark, Space image, f fit, Esc cancel. Needs no IMU."}},
"every tower has one."}}, {"gimbal heading mark <deg>",
{"gimbal heading fit <counts>:<deg> <counts>:<deg> [...]", "Record where the gimbal points now as the landmark's true bearing.", {
"Fit the yaw map directly from known (encoder position, bearing) pairs.", { "Only during 'gimbal heading setup'. Example: gimbal heading mark 137.4"}},
"What the 'H' wizard emits, usable by hand on a headless tower. Two pairs", {"gimbal heading fit [<counts>:<deg> ...]",
"fit exactly; more are least-squares fitted and report R^2. Pairs closer", "Fit the yaw map from the marks, or from (encoder position, bearing) pairs.", {
"than 10 deg apart are refused - the fit would look perfect and be wrong.", "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"}}, "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>", {"gimbal heading offset <deg>",
"Shift the whole yaw map by a known amount and save it.", { "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", "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", {"imu status",
"Print the latest orientation reading.", { "Print the latest orientation reading.", {
"Yaw/pitch/roll, device temperature and sample counter. The Sensors panel", "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", {"imu config",
"Re-read the device configuration and print it.", { "Re-read the device configuration and print it.", {
"Identity, firmware, sample rate, active XKF profile and the available", "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"}}, "Example: trace serial on / trace off"}},
}}, }},
{"Display", "Terminal dashboard keys (TUI only).", { {"Display", "Terminal dashboard keys (TUI only).", {
{"? / g / i / c / m", {"a / p / m",
"Toggle the Help, Gimbal, Sensors, Camera and Comms overlays.", { "Auto mode / pan around where the gimbal points now / manual mode.", {
"An overlay takes the full terminal height. Esc closes it; with none open,", "p is 'mode pan <current heading> 30 <current pitch>'. m also cancels a",
"Esc cancels a running procedure. 'm' shows broker state, per-topic traffic", "running test, homing or calibration, as Esc does."}},
"and the recent MQTT messages, including any dropped while offline."}}, {"? / 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", {"Space",
"Take one image now and publish its CamEvent (manual mode only).", { "Take one image now and publish its CamEvent (manual mode only).", {
"Goes through the same settle-and-trigger path a scan waypoint uses, so", "Goes through the same settle-and-trigger path a scan waypoint uses, so",
"the file and the CamEvent are indistinguishable from a scan frame.", "the file and the CamEvent are indistinguishable from a scan frame."}},
"Refused in auto and pan."}}, {"f",
{"H", "Freeze the screen: to select and copy text, or to scroll back.", {
"Open the heading-setup wizard (see 'gimbal heading setup').", { "While frozen nothing new is drawn, so the log stays where you page it.",
"Manual mode only. Blocks a scan from starting until it is finished or", "The header says when it froze. f again resumes."}},
"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.", {}},
{"q", {"q",
"Ask whether to quit or leave the dashboard running in the background.", { "Ask whether to quit or leave the dashboard running in the background.", {
"Quitting stops capture and closes the camera; backgrounding detaches the", "Quitting stops capture and closes the camera; backgrounding detaches the",
"tmux client (same as Ctrl-b d) and leaves everything running. Ctrl-C still", "tmux client (same as Ctrl-b d) and leaves everything running. Ctrl-C still",
"shuts down immediately."}}, "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.", { {"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]", {"help [topic]",
"Show this reference; 'help <topic>' expands one section.", { "Show this reference; 'help <topic>' expands one section.", {
"Example: help positioning / help imu"}}, "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& verb = w[0];
const std::string sub = w.size() > 1 ? w[1] : ""; const std::string sub = w.size() > 1 ? w[1] : "";
if (verb == "help" || verb == "mode" || verb == "cancel" || verb == "exit" || if (verb == "help" || verb == "mode" || verb == "cancel" || verb == "exit" ||
verb == "debug" || verb == "trace" || verb == "refresh") verb == "debug" || verb == "trace")
return true; return true;
if (verb == "gimbal") return sub == "status" || sub == "dump"; if (verb == "gimbal") return sub == "status" || sub == "dump";
if (verb == "imu") return sub == "status" || sub == "config"; if (verb == "imu") return sub == "status" || sub == "config";

View File

@ -15,6 +15,7 @@
#include <chrono> #include <chrono>
#include <cmath> #include <cmath>
#include <cstdlib> #include <cstdlib>
#include <cstring>
#include <cstdio> #include <cstdio>
#include <map> #include <map>
#include <sstream> #include <sstream>
@ -99,7 +100,11 @@ Decorator reflectHeight(int& out) {
struct ScrollState { struct ScrollState {
int top = 0; // first body LINE we want visible 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 // Measured by the previous frame: the body's full requested height, and the
// visible height of the pane it is framed into. // visible height of the pane it is framed into.
@ -111,6 +116,17 @@ struct ScrollState {
bool scrollable() const { return scrollMax(contentH(), viewH()) > 0; } 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 `body` so line `st.top` sits at the top of the pane.
// //
// Frame::SetBox CENTRES the viewport on the anchor (dy = selected.y_min - // 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 // something off-screen, so the hint doubles as the "there is more below" cue on
// a pane that happens to fit. // a pane that happens to fit.
Element overlayTitle(const std::string& name, const std::string& keys, const ScrollState& st, 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; std::string hint = keys;
if (st.scrollable()) if (st.scrollable()) hint += " PgUp/PgDn:scroll";
hint += arrows_scroll ? " \xE2\x86\x91\xE2\x86\x93/PgUp/PgDn:scroll" : " PgUp/PgDn:scroll";
return text(" " + name + " (" + hint + ") ") | bold | color(c); return text(" " + name + " (" + hint + ") ") | bold | color(c);
} }
Element cameraDetailTitle(const ScrollState& st) { 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 // 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 // Which overlay owns the body. Declared here so keyBar() can switch on it; the
// render loop keeps the authoritative value. // 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 // 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 // they can identify on a map. The marks and the fit belong to the program
// has one, and IMU yaw drifts too much to be a heading reference. // (`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.
// 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.
enum class JogPhase { Coarse, Fine, VeryFine }; enum class JogPhase { Coarse, Fine, VeryFine };
@ -204,19 +216,6 @@ long jogStepCounts(JogPhase p, long travel_counts) {
return step > kVeryFineCounts ? step : kVeryFineCounts; 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 // 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. // so the operator can judge the phase without doing the arithmetic.
std::string jogStepLabel(JogPhase p, long travel_counts, double counts_per_deg) { 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; 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 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 - // 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. // is a hint about what is useful here, not a restriction.
// //
// flexbox (Wrap::Wrap by default) rather than hbox: the overview set is wider // 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"). // than an 80-column terminal, and hbox silently truncated it mid-word ("q Qui").
Element keyBar(Overlay overlay, bool manual) { Element keyBar(Overlay overlay, bool manual, bool heading_setup) {
static const char* kArrowsV = "\xE2\x86\x91\xE2\x86\x93";
static const char* kArrows4 = "\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93"; 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;
Elements e{keyHint("s", "Auto"), keyHint("x", "Manual")}; 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) { switch (overlay) {
case Overlay::None: case Overlay::None:
e.push_back(keyHint("h", "Home")); e.push_back(keyHint("h", "Home"));
e.push_back(keyHint("r", "Refresh")); e.push_back(keyHint("g s c n", "Views"));
e.push_back(keyHint("g i c m", "Views")); e.push_back(keyHint("f", "Pause"));
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")); e.push_back(keyHint("PgUp/Dn", "Log"));
break; break;
case Overlay::Help: case Overlay::Help:
e.push_back(keyHint(kArrowsV, "Section")); e.push_back(keyHint("j/k", "Section"));
e.push_back(keyHint("PgUp/Dn", "Scroll")); e.push_back(keyHint("PgUp/Dn", "Scroll"));
break; break;
case Overlay::Gimbal: case Overlay::Gimbal:
e.push_back(keyHint("d", "Dump")); e.push_back(keyHint("d", "Dump"));
e.push_back(keyHint(kArrowsV, "Scroll")); e.push_back(keyHint("PgUp/Dn", "Scroll"));
e.push_back(keyHint("PgUp/Dn", "Page"));
e.push_back(keyHint("Home/End", "Ends"));
break; break;
case Overlay::Sensors: 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::Cameras:
case Overlay::Conn: case Overlay::Conn:
e.push_back(keyHint(kArrowsV, "Scroll")); e.push_back(keyHint("PgUp/Dn", "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"));
break; 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")); if (overlay != Overlay::None) e.push_back(keyHint("Esc", "Close"));
}
e.push_back(keyHint(":", "Cmd")); e.push_back(keyHint(":", "Cmd"));
e.push_back(keyHint("?", "Help")); e.push_back(keyHint("?", "Help"));
e.push_back(keyHint("q", "Quit")); e.push_back(keyHint("q", "Quit"));
@ -432,13 +362,13 @@ Element sensorsPanel(const SensorsView& s) {
} }
return vbox(std::move(rows)); 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, // 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. // so the readings themselves can never reveal it - it has to be said here.
Element imu_badge = s.imu_config_alert Element imu_badge = s.imu_config_alert
? (text(" IMU CFG ") | color(Color::Red) | bold) ? (text(" IMU CFG ") | color(Color::Red) | bold)
: text(""); : text("");
return panel("[i] SENSORS", Color::Magenta, return panel("[s] SENSORS", Color::Magenta,
vbox({group(s.imu, std::move(imu_badge)), separator(), vbox({group(s.imu, std::move(imu_badge)), separator(),
group(s.env, text(""))})); group(s.env, text(""))}));
} }
@ -481,7 +411,7 @@ Element cameraPanel(const CaptureView& c) {
} else { } else {
rows.push_back(labelRow("last", text("\xE2\x80\x94") | dim)); 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 // 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) Element mqtt = !v.mqtt_enabled ? (text(" disabled ") | dim)
: v.mqtt_connected ? (text(" connected ") | color(Color::Green) | bold) : v.mqtt_connected ? (text(" connected ") | color(Color::Green) | bold)
: (text(" disconnected ") | color(Color::Red) | 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("MQTT", hbox({mqtt, filler(), text(v.broker) | dim})),
labelRow("tower", text(v.tower)), labelRow("tower", text(v.tower)),
labelRow("mode", hbox({text(v.mode), filler(), 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. // traffic, the counters, and a scrollable history of recent messages.
// //
// The history is the point of the view. `dropped` in particular has no other // 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)); 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; std::vector<Element> rows;
for (const auto& l : lines) { for (const auto& l : lines) {
Color c = Color::Default; 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)); rows.push_back(text(l.text) | color(c));
} }
if (rows.empty()) rows.push_back(text("(no log output yet)") | dim); if (rows.empty()) rows.push_back(text("(no log output yet)") | dim);
// Frozen scrollback withholds new lines, which is invisible without a cue. // Which file this is going to, so it can be found afterwards; and a cue when
Element label = text(" LOG ") | bold | color(Color::GrayLight); // the view is held back from the newest line.
Element title = label; Element title = hbox({text(" LOG ") | bold | color(Color::GrayLight),
if (!st.follow) text(file.empty() ? "(no log file) " : homeShort(file) + " ") | dim,
title = hbox({label, text("scrollback ") | color(Color::Yellow) | bold, st.follow ? text("")
text("(End:follow) ") | dim}); : text("scrollback ") | color(Color::Yellow) | bold});
else if (st.scrollable())
title = hbox({label, text("(PgUp:scrollback) ") | dim});
return window(title, scrollableBody(vbox(std::move(rows)), st)); return window(title, scrollableBody(vbox(std::move(rows)), st));
} }
// Compact strip below the log: the running special op (live) + the last // Compact strip below the log: the running special op (live) + the last
// calibration/diagnostics result (persists so it doesn't scroll away). // 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; std::vector<Element> rows;
if (a.active) { if (a.active) {
rows.push_back(hbox({ rows.push_back(hbox({
text(" \xE2\x96\xB6 ") | color(Color::Cyan) | bold, text(" \xE2\x96\xB6 ") | color(Color::Cyan) | bold,
text(a.title + ": ") | bold, text(a.title + ": ") | bold,
text(a.status) | color(Color::Cyan), 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) { } else if (a.has_result) {
rows.push_back(text(" idle") | dim); 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)})); rows.push_back(hbox({text(" "), text(l) | color(Color::Green)}));
} else { } else {
rows.push_back(hbox({text(" "), rows.push_back(hbox({text(" "),
text("(none captured yet - run 'dump')") | dim})); text("(none captured yet - run 'gimbal dump')") | dim}));
} }
} }
rows.push_back(text("")); rows.push_back(text(""));
} }
return window(overlayTitle("HELP", "?:close Up/Down:section", st, Color::Cyan, return window(overlayTitle("HELP", "?:close j/k:section", st, Color::Cyan),
/*arrows_scroll=*/false),
scrollableBody(vbox(std::move(rows)), st)); 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})); 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 // then the ambient sensor. The two devices get their own headed sections, and
// each temperature is labelled with its provenance ("internal" vs "ambient"), // each temperature is labelled with its provenance ("internal" vs "ambient"),
// so the reading the main window shows is never confused with the MTi's. // 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)); bold));
// Scrolled so a long body (MTi channels + config + ambient) stays reachable on // Scrolled so a long body (MTi channels + config + ambient) stays reachable on
// a short terminal instead of being silently cut off. // 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(), vbox({hbox({imu_status, text(" "), env_status, filler()}), separator(),
scrollableBody(vbox(std::move(body)), st) | flex})); scrollableBody(vbox(std::move(body)), st) | flex}));
} }
@ -1302,6 +1242,7 @@ void TuiUi::refreshLoop() {
void TuiUi::uiLoop() { void TuiUi::uiLoop() {
std::string cmd_buffer; std::string cmd_buffer;
int cmd_cursor = 0;
bool command_mode = false; bool command_mode = false;
Overlay overlay = Overlay::None; // which takeover panel owns the main area Overlay overlay = Overlay::None; // which takeover panel owns the main area
int help_sel = 0; int help_sel = 0;
@ -1310,55 +1251,73 @@ void TuiUi::uiLoop() {
// Mirrored out of the snapshot each render: CatchEvent runs on the same // Mirrored out of the snapshot each render: CatchEvent runs on the same
// thread but has no access to the renderer's local snapshot copy. // 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 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 // Paused: the whole frame is held, log included, so what the operator pages
// buffer and history semantics untouched. // through or selects does not move under them.
HeadingWizard wiz; UiSnapshot frozen;
std::string bearing_buffer; bool have_frozen = false;
long wiz_yaw_counts = 0; // live yaw position, mirrored for CatchEvent
long wiz_yaw_travel = 0;
long wiz_pitch_travel = 0;
// Per-pane scroll positions. UI-thread-only view state (the renderer lambda // Per-pane scroll positions. UI-thread-only view state (the renderer lambda
// and CatchEvent below both run on this thread), so unlike screen_/paused_ // and CatchEvent below both run on this thread), so unlike screen_/paused_
// these need no synchronisation and stay locals rather than TuiUi members. // these need no synchronisation and stay locals rather than TuiUi members.
ScrollState cam_scroll, sensors_scroll, help_scroll, log_scroll, conn_scroll; ScrollState cam_scroll, sensors_scroll, help_scroll, log_scroll, conn_scroll;
std::array<ScrollState, 2> gimbal_scroll{}; // YAW, PITCH 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_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"); InputOption input_opt;
auto bearing_input = Input(&bearing_buffer, "e.g. 137.4"); input_opt.placeholder = "type a command, Enter to run, Esc to cancel";
// Only one text field is ever live, so a Tab container (rather than a input_opt.cursor_position = &cmd_cursor;
// Vertical) keeps focus unambiguous: index 0 = command line, 1 = the wizard's auto input = Input(&cmd_buffer, input_opt);
// 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);
auto renderer = Renderer(inputs, [&] { auto renderer = Renderer(input, [&] {
UiSnapshot s = snapshot_ ? snapshot_() : UiSnapshot{}; UiSnapshot s;
size_t log_dropped_now = 0; 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_); std::lock_guard<std::mutex> lock(log_mutex_);
s.log.assign(log_.begin(), log_.end()); s.log.assign(log_.begin(), log_.end());
log_dropped_now = log_dropped_; log_dropped_now = log_dropped_;
} }
// Evicting from the front of the ring buffer slides every line up under a if (paused_ && !have_frozen) { frozen = s; have_frozen = true; }
// frozen viewport, so a busy log would drag the view the operator parked. if (!paused_) have_frozen = false;
// Absorb the shift; the clamp in scrollableBody handles running off the top. 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) { 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_scroll.top -= static_cast<int>(log_dropped_now - log_dropped_at_freeze);
log_dropped_at_freeze = log_dropped_now; 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(); calib_prompt = !s.activity.prompt.empty();
manual_mode = s.capture.manual; manual_mode = s.capture.manual;
wiz_yaw_counts = s.gimbal.yaw.xenc; heading_setup = s.activity.heading_setup;
wiz_yaw_travel = s.gimbal.yaw.has_limits ? std::labs(s.gimbal.yaw.lim_pos - s.gimbal.yaw.lim_neg) : 0; yaw_deg = s.gimbal.yaw.deg;
wiz_pitch_travel = 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; 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 gimbal_overlay_open_.store(overlay == Overlay::Gimbal); // refreshLoop polls a dump while open
std::string mode = s.header.live ? "LIVE" : "MOCK"; std::string mode = s.header.live ? "LIVE" : "MOCK";
@ -1367,10 +1326,6 @@ void TuiUi::uiLoop() {
text(" " + s.header.tower + " ") | bold, text(" " + s.header.tower + " ") | bold,
text("build " + s.header.build) | dim, text("build " + s.header.build) | dim,
filler(), 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 // Says WHEN it froze, not just that it is frozen. Without the time a
// held frame is indistinguishable from a hung program - the numbers // held frame is indistinguishable from a hung program - the numbers
// simply stop - which during a multi-minute home reads as a crash. // simply stop - which during a multi-minute home reads as a crash.
@ -1399,7 +1354,7 @@ void TuiUi::uiLoop() {
Element bottom = command_mode Element bottom = command_mode
? hbox({text(" : ") | inverted, input->Render() | flex}) | border ? hbox({text(" : ") | inverted, input->Render() | flex}) | border
: exit_prompt ? exitPrompt(backgroundAvailable(std::getenv("TMUX"))) : 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 // An open overlay takes over the whole body (the dashboard panels are
// hidden) so it has the full height — otherwise tall content like the // 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}); return vbox({header, separator(), cameraDetailPanel(s.capture, cam_scroll) | flex, bottom});
case Overlay::Conn: case Overlay::Conn:
return vbox({header, separator(), connDetailPanel(s.conn, conn_scroll) | flex, bottom}); 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: { default: {
// Activity strip sits between the log and the key bar; shown only // 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. // once a special op has run or is running, else it costs no space.
std::vector<Element> col = {header, separator(), top, middle, std::vector<Element> col = {header, separator(), top, middle,
logPanel(s.log, log_scroll) | flex}; logPanel(s.log, s.log_file, log_scroll) | flex};
if (s.activity.active || s.activity.has_result || !s.activity.prompt.empty()) if (s.activity.active || s.activity.has_result || !s.activity.prompt.empty()) {
col.push_back(activityPanel(s.activity)); 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); col.push_back(bottom);
return vbox(std::move(col)); return vbox(std::move(col));
} }
} }
}); });
// Apply a scroll key to one pane. `arrows` is false where Up/Down already mean // PgUp/PgDn are the only scroll keys: the arrows belong to the gimbal. Paging
// something else (help section nav; gimbal nudge on the dashboard), leaving // up from the bottom leaves "follow" where the view stood; paging back down
// PgUp/PgDn/Home/End as the only scroll keys there. // to the bottom resumes it.
auto scrollKeys = [](ScrollState& st, const Event& e, bool arrows) { auto scrollKeys = [](ScrollState& st, const Event& e) {
if (e != Event::PageUp && e != Event::PageDown) return false;
const int ch = st.contentH(), vh = st.viewH(); const int ch = st.contentH(), vh = st.viewH();
if (arrows && (e == Event::ArrowDown)) { st.top = scrollBy(st.top, 1, ch, vh); return true; } if (st.follow) {
if (arrows && (e == Event::ArrowUp)) { st.top = scrollBy(st.top, -1, ch, vh); return true; } st.follow = false;
if (e == Event::PageDown) { st.top = scrollPage(st.top, 1, ch, vh); return true; } st.top = scrollMax(ch, vh);
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; }
} }
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) { auto root = CatchEvent(renderer, [&](Event e) {
@ -1458,11 +1413,13 @@ void TuiUi::uiLoop() {
if (e == Event::Return) { if (e == Event::Return) {
if (!cmd_buffer.empty() && sink_) sink_(cmd_buffer); if (!cmd_buffer.empty() && sink_) sink_(cmd_buffer);
cmd_buffer.clear(); cmd_buffer.clear();
cmd_cursor = 0;
command_mode = false; command_mode = false;
return true; return true;
} }
if (e == Event::Escape) { if (e == Event::Escape) {
cmd_buffer.clear(); cmd_buffer.clear();
cmd_cursor = 0;
command_mode = false; command_mode = false;
return true; return true;
} }
@ -1493,155 +1450,71 @@ void TuiUi::uiLoop() {
return true; // swallow everything else return true; // swallow everything else
} }
// The heading wizard owns the keyboard while it is up, like the exit // Heading setup runs in the activity strip and takes the keys it needs:
// prompt: a stray key must not reach the dashboard behind it and move the // arrows aim in the chosen step, Enter starts a mark on the command line,
// gimbal between a sighting and the bearing being typed for it. // f fits, Esc cancels. Everything else still works (views, help, quit).
if (overlay == Overlay::Heading) { if (heading_setup && sink_) {
auto jog = [&](const char* axis, long travel, long sign) { auto jog = [&](const char* axis, long travel, long sign) {
if (!sink_) return; const long step = jogStepCounts(jog_phase, travel) * sign;
const long step = jogStepCounts(wiz.phase, travel) * sign;
sink_(std::string("gimbal jog ") + axis + " " + std::to_string(step)); sink_(std::string("gimbal jog ") + axis + " " + std::to_string(step));
}; };
if (e == Event::ArrowLeft) { jog("yaw", yaw_travel, -1); return true; }
if (wiz.entering) { if (e == Event::ArrowRight) { jog("yaw", yaw_travel, +1); return true; }
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; }
// Pitch too: the landmark has to be in frame vertically before its // Pitch too: the landmark has to be in frame vertically before its
// bearing means anything. // bearing means anything.
if (e == Event::ArrowUp) { 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", wiz_pitch_travel, -1); return true; } if (e == Event::ArrowDown) { jog("pitch", pitch_travel, -1); return true; }
if (e == Event::Tab) { if (e == Event::Tab) {
wiz.phase = wiz.phase == JogPhase::Coarse ? JogPhase::Fine jog_phase = jog_phase == JogPhase::Coarse ? JogPhase::Fine
: wiz.phase == JogPhase::Fine ? JogPhase::VeryFine : jog_phase == JogPhase::Fine ? JogPhase::VeryFine
: JogPhase::Coarse; : JogPhase::Coarse;
return true; return true;
} }
if (e == Event::Return) { if (e == Event::Return) {
// Freeze the position now: the operator is about to look away to cmd_buffer = "gimbal heading mark ";
// read a bearing off a map, and the field must belong to where the cmd_cursor = static_cast<int>(cmd_buffer.size());
// gimbal is pointing at this moment. command_mode = true;
wiz.pending_counts = wiz_yaw_counts;
wiz.entering = true;
wiz.note.clear();
bearing_buffer.clear();
return true; return true;
} }
if (e == Event::Escape) { if (e == Event::Escape && overlay == Overlay::None) {
overlay = Overlay::None; sink_("gimbal heading cancel");
wiz = {};
bearing_buffer.clear();
if (sink_) sink_("gimbal heading cancel");
return true; return true;
} }
if (e.is_character()) { if (e == Event::Character("f")) { sink_("gimbal heading fit"); return true; }
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
} }
const int n = static_cast<int>(helpCatalog().size()); const int n = static_cast<int>(helpCatalog().size());
if (overlay == Overlay::Help) { // help pane navigation // Scroll the view that owns the body, or the log. PgUp/PgDn only.
// 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) { switch (overlay) {
case Overlay::Cameras: case Overlay::None: if (scrollKeys(log_scroll, e)) {
if (scrollKeys(cam_scroll, e, arrows)) return true; std::lock_guard<std::mutex> lock(log_mutex_);
break; log_dropped_at_freeze = log_dropped_;
case Overlay::Conn: return true;
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; 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: { case Overlay::Gimbal: {
// Both axis columns move together: they are read side by side, // Both axis columns move together: they are read side by side,
// and each clamps against its own measured height. // and each clamps against its own measured height.
const bool a = scrollKeys(gimbal_scroll[0], e, arrows); const bool a = scrollKeys(gimbal_scroll[0], e);
const bool b = scrollKeys(gimbal_scroll[1], e, arrows); const bool b = scrollKeys(gimbal_scroll[1], e);
if (a || b) return true; if (a || b) return true;
break; break;
} }
case Overlay::None: break;
}
} }
if (overlay != Overlay::None && e == Event::Escape) { overlay = Overlay::None; return true; } if (overlay != Overlay::None && e == Event::Escape) { overlay = Overlay::None; return true; }
// Esc with no overlay open cancels a running procedure (test/calib/homing). // Esc with no overlay open cancels a running procedure (test/calib/homing).
if (overlay == Overlay::None && e == Event::Escape && sink_) { if (overlay == Overlay::None && e == Event::Escape && sink_) {
if (snapshot_ && snapshot_().activity.cancelable) { sink_("cancel"); return true; } if (snapshot_ && snapshot_().activity.cancelable) { sink_("cancel"); return true; }
} }
// Log scrollback. The arrows belong to the gimbal here, so this is // The arrows nudge the gimbal, from any view: left/right = yaw -/+5%,
// PgUp/PgDn/Home/End only; End resumes following the tail. // up/down = pitch -/+10% of travel. Only in manual mode: in auto and pan
if (overlay == Overlay::None) { // the app refuses these anyway, and a silent no-op beats a warning per
if (e == Event::End) { // keypress from someone leaning on an arrow.
log_scroll.follow = true; if (manual_mode && sink_) {
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_) {
if (e == Event::ArrowLeft) { sink_("gimbal nudge yaw -5"); return true; } 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::ArrowRight) { sink_("gimbal nudge yaw 5"); return true; }
if (e == Event::ArrowUp) { sink_("gimbal nudge pitch -10"); 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 == "y" || c == "Y") { sink_("calib save"); return true; }
if (c == "n" || c == "N") { sink_("calib discard"); return true; } if (c == "n" || c == "N") { sink_("calib discard"); return true; }
} }
if (c == "?") { auto toggle = [&](Overlay o, ScrollState* st) {
overlay = (overlay == Overlay::Help) ? Overlay::None : Overlay::Help; overlay = (overlay == o) ? Overlay::None : o;
if (overlay == Overlay::Help) help_scroll = {}; if (overlay == o && st) *st = {};
return true; };
} if (c == "?") { toggle(Overlay::Help, &help_scroll); return true; }
if (c == "g") { if (c == "g") {
if (overlay == Overlay::Gimbal) { if (overlay == Overlay::Gimbal) {
overlay = Overlay::None; overlay = Overlay::None;
@ -1676,49 +1549,34 @@ void TuiUi::uiLoop() {
if (sink_) sink_("gimbal dump"); if (sink_) sink_("gimbal dump");
return true; return true;
} }
if (c == "i") { if (c == "s") { toggle(Overlay::Sensors, &sensors_scroll); return true; }
overlay = (overlay == Overlay::Sensors) ? Overlay::None : Overlay::Sensors; if (c == "c") { toggle(Overlay::Cameras, &cam_scroll); return true; }
if (overlay == Overlay::Sensors) sensors_scroll = {}; if (c == "n") { toggle(Overlay::Conn, &conn_scroll); return true; }
return true; if (c == "f") { // freeze/unfreeze: to select text, or to read back
}
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
paused_ = !paused_; paused_ = !paused_;
if (paused_) paused_at_hms_ = nowHms(); if (paused_) paused_at_hms_ = nowHms();
return true; // this keypress redraws once so the indicator updates 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 == "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 == "k") { help_sel = (help_sel - 1 + n) % n; help_scroll = {}; return true; }
} }
if (c == "q") { exit_prompt = true; return true; } if (c == "q") { exit_prompt = true; return true; }
if (c == "s") { if (sink_) sink_("mode auto"); return true; } if (c == "a") { if (sink_) sink_("mode auto"); return true; }
if (c == "x") { if (sink_) sink_("mode manual"); return true; } if (c == "m") { if (sink_) sink_("mode manual"); return true; }
if (c == "h") { if (sink_) sink_("gimbal home"); return true; } // Pan 30 degrees around where the gimbal points now.
if (c == "r") { if (sink_) sink_("refresh"); return true; } // IMU config + dump if (c == "p") {
if (c == ":") { command_mode = true; return true; } if (sink_) {
// Heading setup: only from the dashboard, and only in manual mode - the std::string cmd = "mode pan " + num(yaw_deg, 1) + " 30";
// app refuses to arm it during a scan, so opening the pane would strand if (pitch_present) cmd += " " + num(pitch_deg, 1);
// the operator in a wizard that cannot record anything. sink_(cmd);
if (c == "H") { }
if (!manual_mode) return true;
overlay = Overlay::Heading;
wiz = {};
bearing_buffer.clear();
if (sink_) sink_("gimbal heading setup");
return true; 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. // 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; } if (c == " ") { if (sink_) sink_("snapshot"); return true; }
return false; return false;
}); });

View File

@ -88,7 +88,7 @@ SensorsView pendingSensorsView() {
// temperature is deliberately NOT here — it is diagnostic detail shown only // 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 // in the expanded view, so the sole temperature on the main window is the
// SHT41's ambient one below. // SHT41's ambient one below.
v.imu.title = "MTi (orientation)"; v.imu.title = "IMU (orientation)";
v.imu.status = "pending"; v.imu.status = "pending";
v.imu.fields.push_back({"Roll", "--.-", "\xC2\xB0", false}); v.imu.fields.push_back({"Roll", "--.-", "\xC2\xB0", false});
v.imu.fields.push_back({"Pitch", "--.-", "\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}) { for (Mode m : {Mode::Auto, Mode::Pan}) {
CAPTURE(modeName(m)); CAPTURE(modeName(m));
for (const char* ok : {"help", "help imu", "gimbal status", "gimbal dump", "imu status", 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"}) { "mode manual", "mode pan 90 30", "cancel", "exit", "GIMBAL DUMP"}) {
CAPTURE(ok); CAPTURE(ok);
CHECK(allowedIn(m, ok)); CHECK(allowedIn(m, ok));