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:
parent
f4e67666d8
commit
79225bc3cd
38
README.md
38
README.md
|
|
@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
**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>`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
1
main.cpp
1
main.cpp
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"}},
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
562
src/ui/TuiUi.cpp
562
src/ui/TuiUi.cpp
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue